Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
verified: 2026-10-17
pins:
- sdk 10.0.4xx
source: measured in a host without TypeKit (Microsoft.AspNetCore.App 10.0 of the pinned SDK, an [ApiController] with an int parameter per binding source); the API reference of MvcOptions.MaxValidationDepth says only that "ValidationVisitor will throw if traversing an object exceeds the maximum allowed validation depth" and that "The default value is 32", nothing of binding errors
turned-on: 28 rows of BaseProbe (claim C07-fault-responses-blanket) under mvc.MaxValidationDepth=1, where a validated value object answered a value its Create refuses with 200 holding its default (WS2 step 4 triage)
test: test/CodoMetis.TypeKit.Facts/KB037.cs
---

# KB-037. Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default

With `MvcOptions.MaxValidationDepth = 1`, an `[ApiController]` action whose `int` parameter MVC cannot bind from
the text it was sent runs anyway. The parameter is `0`, its default, and the action answers 200. That holds for a
route, query, header, form and body parameter alike. With the default depth, 32, every one of those requests is a
400 from the model-state filter.

| Request to an `int` parameter | `MaxValidationDepth = 1` | default (32) |
|---|---|---|
| route, query, header or form `abc`, body `"abc"` | 200, the parameter `0` | 400 |
| the same with `5` | 200, `5` | 200, `5` |
| `[FromQuery]` class whose `int` property gets `abc` or `5` | `InvalidOperationException`: "ValidationVisitor exceeded the maximum configured validation depth '1' when validating property 'V'" | 400 for `abc`, 200 for `5` |

So under that setting a binding failure leaves no 400 to compare: a value object whose text is rejected arrives at
the action as its default, an uninitialized instance that went through no factory, as the wrapped type arrives as
its own default. The HTTP probes compare the two in the same row and cite this entry where both hold their default.
18 changes: 18 additions & 0 deletions design/knowledge/KB-038-a-byte-array-is-a-base64-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
verified: 2026-10-17
pins:
- sdk 10.0.4xx
source: the System.Array section of Supported types in System.Text.Json (learn.microsoft.com, dotnet/docs supported-types.md), which says "`byte[]` is handled specially and serializes as a base64 string, not a JSON array."; measured with System.Text.Json of the pinned SDK
turned-on: row 83 of BaseProbe (claim C07-fault-responses-blanket), where an array of value objects over byte was written [7] and the wrapped twin, a byte[], "Bw=="; docs/json.md "The wrapped type's JSON" and the not-promised entry a-byte-array-is-base64 of claim L01-json-bytes
test: test/CodoMetis.TypeKit.Facts/KB038.cs
---

# KB-038. System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array

`JsonSerializer.Serialize(new byte[] { 7, 255 })` is `"B/8="`, and a `byte[]` reads back from a base64 string,
never from `[7,255]`. A `List<byte>` is `[7,255]`, and an array of any other element, a struct holding a byte
included, is a JSON array.

So swapping `byte` for a value object over `byte` in an array changes the array's JSON: each element keeps the
byte's JSON, a number, and the array becomes a JSON array of numbers instead of the base64 string (docs/json.md,
"The wrapped type's JSON").
2 changes: 2 additions & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ entry's frontmatter, write the test first where a test can observe it, and add a
| [KB-034](KB-034-numeric-p-s-rounds-on-write.md) | `numeric(p,s)` rounds a value with more fractional digits on write | PostgreSQL 17.11, Npgsql EF 10.0.3, EF Core 10.0.12 | `KB034`, with `TYPEKIT_PG` |
| [KB-035](KB-035-npgsql-refuses-non-utc-for-timestamptz.md) | Npgsql refuses a non-UTC `DateTime` and a non-zero-offset `DateTimeOffset` for `timestamptz` | PostgreSQL 17.11, Npgsql EF 10.0.3, EF Core 10.0.12 | `KB035`, with `TYPEKIT_PG` |
| [KB-036](KB-036-a-char-key-gets-an-identity-postgresql-refuses.md) | A plain `char` key gets an identity column on Npgsql, which PostgreSQL refuses | PostgreSQL 17.11, Npgsql EF 10.0.3, EF Core 10.0.12 | `KB036`, with `TYPEKIT_PG` |
| [KB-037](KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md) | Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default | SDK 10.0.4xx | `KB037` |
| [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` |
3 changes: 3 additions & 0 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ of options.
`InvalidOperationException`, because the attribute is valid only on a number. Set the number handling
on the options instead, or in a source-generated context's
`[JsonSourceGenerationOptions(NumberHandling = ...)]`. Both reach the value object.
- **An array of value objects over `byte` is a JSON array of numbers.** Each element is written as its byte,
so you get `[7,255]`, not the base64 string `"B/8="` that a `byte[]` becomes. Keep a `byte[]` where a client
expects base64.

## When a value is rejected

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public sealed class ProbeNoteKeyTests

[Fact]
public void The_corpus_notes_are_found() =>
Calls(Sources()).Count(c => c.Method == "Note").ShouldBeGreaterThanOrEqualTo(130, "the corpus made 136 notes when this rule was written");
Calls(Sources()).Count(c => c.Method == "Note").ShouldBeGreaterThanOrEqualTo(100,
"the corpus made 136 notes when this rule was written, and 127 once the http family's outcome notes became checks' citations (WS2 step 4)");

[Fact]
public void Every_note_and_skip_in_the_corpus_names_its_key_as_written() =>
Expand Down
109 changes: 109 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB037.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Net;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace CodoMetis.TypeKit.Facts;

/// <summary>
/// design/knowledge/KB-037: with <c>MvcOptions.MaxValidationDepth = 1</c>, an <c>[ApiController]</c> action whose simple
/// parameter MVC cannot bind (route, query, header, form, body) runs anyway, with the parameter left at its default, and
/// answers 200; a class bound from the query makes the validator throw its depth refusal at its property, whether the
/// property was bound or not. With the default depth every unbindable request is a 400.
/// </summary>
public sealed class KB037
{
public sealed class Q { public int V { get; set; } }

[ApiController]
public sealed class BindController : ControllerBase
{
[HttpGet("kb037/route/{v}")] public IActionResult Route([FromRoute(Name = "v")] int v) => Ok(v);
[HttpGet("kb037/query")] public IActionResult Query([FromQuery(Name = "v")] int v) => Ok(v);
[HttpGet("kb037/header")] public IActionResult Header([FromHeader(Name = "v")] int v) => Ok(v);
[HttpPost("kb037/form")] public IActionResult Form([FromForm(Name = "v")] int v) => Ok(v);
[HttpPost("kb037/body")] public IActionResult Body([FromBody] int v) => Ok(v);
[HttpGet("kb037/params")] public IActionResult Params([FromQuery] Q q) => Ok(q.V);
}

/// <summary>Each binding source of a simple parameter, sent a text no <c>int</c> reads.</summary>
public static TheoryData<string> Simple() => ["route", "query", "header", "form", "body"];

/// <summary>The same, and the property of a class bound from the query.</summary>
public static TheoryData<string> Sources() => ["route", "query", "header", "form", "body", "params"];

private static async Task<(HttpStatusCode Status, string Body)> SendAsync(int? maxValidationDepth, string source, string text = "abc")
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production });
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Services.AddControllers(options => { if (maxValidationDepth is { } depth) options.MaxValidationDepth = depth; })
.ConfigureApplicationPartManager(parts =>
{
parts.ApplicationParts.Clear();
parts.FeatureProviders.Add(new Only());
});

await using var app = builder.Build();
app.MapControllers();
await app.StartAsync(TestContext.Current.CancellationToken);

var request = source switch
{
"route" => new HttpRequestMessage(HttpMethod.Get, $"/kb037/route/{text}"),
"query" => new HttpRequestMessage(HttpMethod.Get, $"/kb037/query?v={text}"),
"header" => new HttpRequestMessage(HttpMethod.Get, "/kb037/header") { Headers = { { "v", text } } },
"form" => new HttpRequestMessage(HttpMethod.Post, "/kb037/form") { Content = new FormUrlEncodedContent([new("v", text)]) },
"body" => new HttpRequestMessage(HttpMethod.Post, "/kb037/body") { Content = new StringContent(int.TryParse(text, out _) ? text : $"\"{text}\"", System.Text.Encoding.UTF8, "application/json") },
_ => new HttpRequestMessage(HttpMethod.Get, $"/kb037/params?V={text}"),
};
var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken);
return (response.StatusCode, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}

[Theory, MemberData(nameof(Simple))]
public async Task Under_MaxValidationDepth_1_an_unbindable_parameter_is_its_default_and_the_action_answers_200(string source)
{
var (status, body) = await SendAsync(1, source);

status.ShouldBe(HttpStatusCode.OK, body);
body.ShouldBe("0", "the int is left at its default");
}

[Theory, InlineData("abc"), InlineData("5")]
public async Task Under_MaxValidationDepth_1_a_class_from_the_query_makes_the_validator_throw_at_its_property(string text)
{
var thrown = await Should.ThrowAsync<InvalidOperationException>(() => SendAsync(1, "params", text));

thrown.Message.ShouldContain("ValidationVisitor exceeded the maximum configured validation depth '1' when validating property 'V'");
}

[Theory, MemberData(nameof(Simple))]
public async Task Under_MaxValidationDepth_1_a_text_the_parameter_reads_binds_as_usual(string source)
{
var (status, body) = await SendAsync(1, source, "5");

status.ShouldBe(HttpStatusCode.OK, body);
body.ShouldBe("5");
}

[Theory, MemberData(nameof(Sources))]
public async Task With_the_default_depth_the_same_request_is_a_400(string source)
{
var (status, body) = await SendAsync(null, source);

status.ShouldBe(HttpStatusCode.BadRequest, body);
}

private sealed class Only : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature) =>
feature.Controllers.Add(typeof(BindController).GetTypeInfo());
}
}
31 changes: 31 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB038.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json;

namespace CodoMetis.TypeKit.Facts;

/// <summary>
/// design/knowledge/KB-038: System.Text.Json writes a <c>byte[]</c> as one base64 string and reads it back from one, where
/// every other collection of bytes, and an array of any other element, is a JSON array.
/// </summary>
public sealed class KB038
{
public readonly record struct Octet(byte Value);

[Fact]
public void A_byte_array_is_a_base64_string() =>
JsonSerializer.Serialize(new byte[] { 7, 255 }).ShouldBe("\"B/8=\"");

[Fact]
public void A_byte_array_reads_back_from_base64_and_not_from_a_JSON_array()
{
JsonSerializer.Deserialize<byte[]>("\"B/8=\"").ShouldBe(new byte[] { 7, 255 });
Should.Throw<JsonException>(() => JsonSerializer.Deserialize<byte[]>("[7,255]"));
}

[Fact]
public void A_list_of_bytes_is_a_JSON_array_of_numbers() =>
JsonSerializer.Serialize(new List<byte> { 7, 255 }).ShouldBe("[7,255]");

[Fact]
public void An_array_of_another_element_over_a_byte_is_a_JSON_array() =>
JsonSerializer.Serialize(new[] { new Octet(7) }).ShouldBe("""[{"Value":7}]""");
}
Loading
Loading