diff --git a/design/knowledge/KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md b/design/knowledge/KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md new file mode 100644 index 00000000..2c6f62e6 --- /dev/null +++ b/design/knowledge/KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md @@ -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. diff --git a/design/knowledge/KB-038-a-byte-array-is-a-base64-string.md b/design/knowledge/KB-038-a-byte-array-is-a-base64-string.md new file mode 100644 index 00000000..c9ccdf2e --- /dev/null +++ b/design/knowledge/KB-038-a-byte-array-is-a-base64-string.md @@ -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` 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"). diff --git a/design/knowledge/README.md b/design/knowledge/README.md index 963b0f86..7698b2df 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -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` | diff --git a/docs/json.md b/docs/json.md index a976104b..44e9c24b 100644 --- a/docs/json.md +++ b/docs/json.md @@ -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 diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ProbeNoteKeyTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ProbeNoteKeyTests.cs index c337e521..6fb968f4 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ProbeNoteKeyTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ProbeNoteKeyTests.cs @@ -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() => diff --git a/test/CodoMetis.TypeKit.Facts/KB037.cs b/test/CodoMetis.TypeKit.Facts/KB037.cs new file mode 100644 index 00000000..4b451e2d --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB037.cs @@ -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; + +/// +/// design/knowledge/KB-037: with MvcOptions.MaxValidationDepth = 1, an [ApiController] 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. +/// +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); + } + + /// Each binding source of a simple parameter, sent a text no int reads. + public static TheoryData Simple() => ["route", "query", "header", "form", "body"]; + + /// The same, and the property of a class bound from the query. + public static TheoryData 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(() => 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 + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) => + feature.Controllers.Add(typeof(BindController).GetTypeInfo()); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB038.cs b/test/CodoMetis.TypeKit.Facts/KB038.cs new file mode 100644 index 00000000..e022b751 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB038.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-038: System.Text.Json writes a byte[] 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. +/// +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("\"B/8=\"").ShouldBe(new byte[] { 7, 255 }); + Should.Throw(() => JsonSerializer.Deserialize("[7,255]")); + } + + [Fact] + public void A_list_of_bytes_is_a_JSON_array_of_numbers() => + JsonSerializer.Serialize(new List { 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}]"""); +} diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index d7d6d4ae..401f9afe 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -11506,1517 +11506,253 @@ "obligations": [ { "id": "the-underlying-types-schema", - "sentence": "The JSON, the database column and the OpenAPI schema are the same as the underlying type's." - }, - { - "id": "nullable-admits-null-as-the-wrapped-does", - "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", - "axes": { - "http.nullability": [ - "nullable" - ] - } - }, - { - "id": "container-elements", - "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", - "axes": { - "shape.position": [ - "list-element", - "array-element", - "immutable-array-element", - "read-only-list-element", - "dictionary-value" - ] - } - }, - { - "id": "dictionary-key", - "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", - "axes": { - "shape.position": [ - "dictionary-key" - ] - } - }, - { - "id": "every-document-version", - "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", - "axes": { - "openapi.OpenApiVersion": [ - "OpenApi2_0", - "OpenApi3_0" - ] - } - } - ], - "not-promised": [ - { - "id": "a-closed-set-is-a-string-enum", - "source": "README.md#what-you-get", - "sentence": "One line per member gives you parsing by name, JSON as the name, an OpenAPI string enum and a text column.", - "axes": { - "shape.kind": [ - "closed-set" - ] - } - }, - { - "id": "own-converter-for-the-value-object", - "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", - "sentence": "That converter then writes it instead of the generated one, so it's described as that converter says (see \"A described type\" below).", - "axes": { - "json.Converters": [ - "own-for-value-object" - ] - } - }, - { - "id": "own-converter-for-the-wrapped-type", - "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", - "sentence": "ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a dictionary's `additionalProperties` of that type.", - "axes": { - "json.Converters": [ - "own-for-wrapped" - ] - } - }, - { - "id": "a-class-value-object-admits-null", - "source": "design/adr/0036-nullable-value-object-admits-null-openapi.md#0036-a-nullable-value-object-admits-null-in-the-openapi-document-wherever-it-appears", - "sentence": "A nullable value object admits null in the OpenAPI document wherever it appears", - "axes": { - "shape.kind": [ - "sealed-record-class", - "validated-class" - ] - } - }, - { - "id": "openapi-2-nullable-component-loses-its-type", - "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", - "sentence": "So under 2.0 a nullable value-object property loses its type, where the underlying type's nullable property keeps it.", - "axes": { - "openapi.OpenApiVersion": [ - "OpenApi2_0" - ], - "openapi.CreateSchemaReferenceId": [ - "default", - "own-prefixing" - ] - } - }, - { - "id": "enum-component-is-its-first-use", - "source": "design/knowledge/KB-027-an-enum-component-is-its-first-use.md#kb-027-the-component-aspnet-makes-for-an-enum-written-by-name-is-the-schema-of-the-first-use-it-meets", - "sentence": "So whether a parameter of the wrapped `E?` admits null in a document with components depends on the endpoints and their order, not on the parameter.", - "axes": { - "json.Converters": [ - "JsonStringEnumConverter" - ], - "http.nullability": [ - "nullable" - ], - "shape.wrapped": [ - "enum" - ] - } - }, - { - "id": "openapi-2-nullable-body-keeps-only-its-null", - "source": "design/knowledge/KB-001-openapi-2-nullable-body-drops-pattern.md#kb-001-under-openapi-20-aspnet-writes-a-nullable-request-body-as-allof-x-nullable-true-and-drops-its-pattern", - "sentence": "A minimal API writes it the same, and a nullable enum body, which has no `format`, keeps nothing but the null:", - "axes": { - "openapi.OpenApiVersion": [ - "OpenApi2_0" - ], - "http.binding": [ - "body" - ], - "http.nullability": [ - "nullable" - ], - "shape.kind": [ - "sealed-record-class", - "validated-class" - ] - } - } - ], - "exclude": [ - { - "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", - "pairs": [ - [ - "json.PreferredObjectCreationHandling=Populate", - "json.ReferenceHandler=Preserve" - ], - [ - "json.PreferredObjectCreationHandling=Populate", - "json.ReferenceHandler=IgnoreCycles" - ] - ] - }, - { - "reason": "System.Text.Json refuses adding resolvers to TypeInfoResolverChain once a JsonSerializerContext is the TypeInfoResolver (JsonRow notes it as substituted, STJ refused it), so the strict context never runs with the chain.", - "pairs": [ - [ - "json.TypeInfoResolver=source-generated-context", - "json.TypeInfoResolverChain=context-then-default" - ] - ] - }, - { - "reason": "Not promised (own-converter-for-the-value-object): a converter of the application's own writes the value object instead of the generated one and is described as it says, so the wrapped type's schema is no reference; the probe notes schema:converter-not-described.", - "pairs": [ - [ - "http.binding=route", - "json.Converters=own-for-value-object" - ], - [ - "http.binding=query", - "json.Converters=own-for-value-object" - ], - [ - "http.binding=header", - "json.Converters=own-for-value-object" - ], - [ - "http.binding=body", - "json.Converters=own-for-value-object" - ], - [ - "http.binding=form", - "json.Converters=own-for-value-object" - ], - [ - "http.binding=as-parameters", - "json.Converters=own-for-value-object" - ], - [ - "http.host=minimal", - "json.Converters=own-for-value-object" - ], - [ - "http.host=mvc", - "json.Converters=own-for-value-object" - ], - [ - "http.host=both", - "json.Converters=own-for-value-object" - ], - [ - "http.host=route-group", - "json.Converters=own-for-value-object" - ], - [ - "http.host=controller-without-ApiController-visible", - "json.Converters=own-for-value-object" - ], - [ - "http.nullability=as-declared", - "json.Converters=own-for-value-object" - ], - [ - "http.nullability=nullable", - "json.Converters=own-for-value-object" - ], - [ - "http.response=result-to-http-result", - "json.Converters=own-for-value-object" - ], - [ - "http.response=composed-fault", - "json.Converters=own-for-value-object" - ], - [ - "http.response=two-faults-same-name", - "json.Converters=own-for-value-object" - ], - [ - "http.response=group-with-fault-status", - "json.Converters=own-for-value-object" - ], - [ - "http.response=endpoint-without-faults", - "json.Converters=own-for-value-object" - ], - [ - "http.response=declared-produces-problem", - "json.Converters=own-for-value-object" - ], - [ - "input.json=valid", - "json.Converters=own-for-value-object" - ], - [ - "input.json=refused-by-create", - "json.Converters=own-for-value-object" - ], - [ - "input.json=wrong-token", - "json.Converters=own-for-value-object" - ], - [ - "input.json=null", - "json.Converters=own-for-value-object" - ], - [ - "input.json=empty-string", - "json.Converters=own-for-value-object" - ], - [ - "input.json=at-type-limit", - "json.Converters=own-for-value-object" - ], - [ - "input.json=quoted-number", - "json.Converters=own-for-value-object" - ], - [ - "input.json=escape-sequence", - "json.Converters=own-for-value-object" - ], - [ - "input.json=duplicate-property", - "json.Converters=own-for-value-object" - ], - [ - "input.json=metadata-property", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowDuplicateProperties=default", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowDuplicateProperties=false", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowOutOfOrderMetadataProperties=default", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowOutOfOrderMetadataProperties=true", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowTrailingCommas=default", - "json.Converters=own-for-value-object" - ], - [ - "json.AllowTrailingCommas=true", - "json.Converters=own-for-value-object" - ], - [ - "json.Converters=own-for-value-object", - "json.DefaultIgnoreCondition=default" - ], - [ - "json.Converters=own-for-value-object", - "json.DefaultIgnoreCondition=WhenWritingDefault" - ], - [ - "json.Converters=own-for-value-object", - "json.DefaultIgnoreCondition=WhenWritingNull" - ], - [ - "json.Converters=own-for-value-object", - "json.DefaultIgnoreCondition=WhenWriting" - ], - [ - "json.Converters=own-for-value-object", - "json.DefaultIgnoreCondition=WhenReading" - ], - [ - "json.Converters=own-for-value-object", - "json.DictionaryKeyPolicy=default" - ], - [ - "json.Converters=own-for-value-object", - "json.DictionaryKeyPolicy=CamelCase" - ], - [ - "json.Converters=own-for-value-object", - "json.DictionaryKeyPolicy=SnakeCaseLower" - ], - [ - "json.Converters=own-for-value-object", - "json.DictionaryKeyPolicy=KebabCaseUpper" - ], - [ - "json.Converters=own-for-value-object", - "json.DictionaryKeyPolicy=own-upper-case" - ], - [ - "json.Converters=own-for-value-object", - "json.Encoder=default" - ], - [ - "json.Converters=own-for-value-object", - "json.Encoder=UnsafeRelaxedJsonEscaping" - ], - [ - "json.Converters=own-for-value-object", - "json.IgnoreReadOnlyFields=default" - ], - [ - "json.Converters=own-for-value-object", - "json.IgnoreReadOnlyFields=true" - ], - [ - "json.Converters=own-for-value-object", - "json.IgnoreReadOnlyProperties=default" - ], - [ - "json.Converters=own-for-value-object", - "json.IgnoreReadOnlyProperties=true" - ], - [ - "json.Converters=own-for-value-object", - "json.IncludeFields=default" - ], - [ - "json.Converters=own-for-value-object", - "json.IncludeFields=true" - ], - [ - "json.Converters=own-for-value-object", - "json.MaxDepth=default" - ], - [ - "json.Converters=own-for-value-object", - "json.MaxDepth=3" - ], - [ - "json.Converters=own-for-value-object", - "json.NumberHandling=default" - ], - [ - "json.Converters=own-for-value-object", - "json.NumberHandling=AllowReadingFromString" - ], - [ - "json.Converters=own-for-value-object", - "json.NumberHandling=WriteAsString" - ], - [ - "json.Converters=own-for-value-object", - "json.NumberHandling=AllowNamedFloatingPointLiterals" - ], - [ - "json.Converters=own-for-value-object", - "json.PreferredObjectCreationHandling=default" - ], - [ - "json.Converters=own-for-value-object", - "json.PreferredObjectCreationHandling=Populate" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNameCaseInsensitive=default" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNameCaseInsensitive=true" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=default" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=CamelCase" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=SnakeCaseLower" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=SnakeCaseUpper" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=KebabCaseLower" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=KebabCaseUpper" - ], - [ - "json.Converters=own-for-value-object", - "json.PropertyNamingPolicy=own-upper-case" - ], - [ - "json.Converters=own-for-value-object", - "json.ReadCommentHandling=default" - ], - [ - "json.Converters=own-for-value-object", - "json.ReadCommentHandling=Skip" - ], - [ - "json.Converters=own-for-value-object", - "json.ReferenceHandler=default" - ], - [ - "json.Converters=own-for-value-object", - "json.ReferenceHandler=Preserve" - ], - [ - "json.Converters=own-for-value-object", - "json.ReferenceHandler=IgnoreCycles" - ], - [ - "json.Converters=own-for-value-object", - "json.RespectNullableAnnotations=default" - ], - [ - "json.Converters=own-for-value-object", - "json.RespectNullableAnnotations=true" - ], - [ - "json.Converters=own-for-value-object", - "json.RespectRequiredConstructorParameters=default" - ], - [ - "json.Converters=own-for-value-object", - "json.RespectRequiredConstructorParameters=true" - ], - [ - "json.Converters=own-for-value-object", - "json.TypeInfoResolver=default" - ], - [ - "json.Converters=own-for-value-object", - "json.TypeInfoResolver=source-generated-context" - ], - [ - "json.Converters=own-for-value-object", - "json.TypeInfoResolver=default-with-renaming-modifier" - ], - [ - "json.Converters=own-for-value-object", - "json.TypeInfoResolverChain=default" - ], - [ - "json.Converters=own-for-value-object", - "json.TypeInfoResolverChain=context-then-default" - ], - [ - "json.Converters=own-for-value-object", - "json.UnknownTypeHandling=default" - ], - [ - "json.Converters=own-for-value-object", - "json.UnknownTypeHandling=JsonNode" - ], - [ - "json.Converters=own-for-value-object", - "json.UnmappedMemberHandling=default" - ], - [ - "json.Converters=own-for-value-object", - "json.UnmappedMemberHandling=Disallow" - ], - [ - "json.Converters=own-for-value-object", - "mvc.AllowEmptyInputInBodyModelBinding=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.AllowEmptyInputInBodyModelBinding=true" - ], - [ - "json.Converters=own-for-value-object", - "mvc.Conventions=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.Conventions=own-route-prefix" - ], - [ - "json.Converters=own-for-value-object", - "mvc.InputFormatters=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.InputFormatters=xml-added" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxModelBindingCollectionSize=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxModelBindingCollectionSize=2" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxModelValidationErrors=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxModelValidationErrors=1" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxValidationDepth=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.MaxValidationDepth=1" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ModelBinderProviders=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ModelBinderProviders=own-provider-first" - ], - [ - "json.Converters=own-for-value-object", - "mvc.OutputFormatters=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.OutputFormatters=xml-added" - ], - [ - "json.Converters=own-for-value-object", - "mvc.RespectBrowserAcceptHeader=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.RespectBrowserAcceptHeader=true" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ReturnHttpNotAcceptable=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ReturnHttpNotAcceptable=true" - ], - [ - "json.Converters=own-for-value-object", - "mvc.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ValidateComplexTypesIfChildValidationFails=default" - ], - [ - "json.Converters=own-for-value-object", - "mvc.ValidateComplexTypesIfChildValidationFails=true" - ], - [ - "json.Converters=own-for-value-object", - "openapi.CreateSchemaReferenceId=default" - ], - [ - "json.Converters=own-for-value-object", - "openapi.CreateSchemaReferenceId=own-prefixing" - ], - [ - "json.Converters=own-for-value-object", - "openapi.CreateSchemaReferenceId=null-inlines-all" - ], - [ - "json.Converters=own-for-value-object", - "openapi.OpenApiVersion=default" - ], - [ - "json.Converters=own-for-value-object", - "openapi.OpenApiVersion=OpenApi2_0" - ], - [ - "json.Converters=own-for-value-object", - "openapi.OpenApiVersion=OpenApi3_0" - ], - [ - "json.Converters=own-for-value-object", - "openapi.transformers=default" - ], - [ - "json.Converters=own-for-value-object", - "openapi.transformers=own-schema-transformer" - ], - [ - "json.Converters=own-for-value-object", - "openapi.transformers=own-operation-transformer" - ], - [ - "json.Converters=own-for-value-object", - "openapi.transformers=own-document-transformer" - ], - [ - "json.Converters=own-for-value-object", - "process.converter-lookup=none" - ], - [ - "json.Converters=own-for-value-object", - "process.converter-lookup=wrapped-type-converter-looked-up-first" - ], - [ - "json.Converters=own-for-value-object", - "shape.containing=class" - ], - [ - "json.Converters=own-for-value-object", - "shape.containing=record-class" - ], - [ - "json.Converters=own-for-value-object", - "shape.containing=struct" - ], - [ - "json.Converters=own-for-value-object", - "shape.containing=record-struct" - ], - [ - "json.Converters=own-for-value-object", - "shape.containing=generic-type" - ], - [ - "json.Converters=own-for-value-object", - "shape.kind=record-struct" - ], - [ - "json.Converters=own-for-value-object", - "shape.kind=sealed-record-class" - ], - [ - "json.Converters=own-for-value-object", - "shape.kind=validated-struct" - ], - [ - "json.Converters=own-for-value-object", - "shape.kind=validated-class" - ], - [ - "json.Converters=own-for-value-object", - "shape.kind=closed-set" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=root" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=property" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=nullable-property" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=field" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=list-element" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=array-element" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=immutable-array-element" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=read-only-list-element" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=dictionary-key" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=dictionary-value" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=record-ctor-parameter" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=required-init" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=in-patch" - ], - [ - "json.Converters=own-for-value-object", - "shape.position=polymorphic-derived-property" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=string" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=Guid" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=int" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=long" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=byte" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=ulong" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=decimal" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=double" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=bool" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=char" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=DateTime" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=DateTimeOffset" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=DateOnly" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=TimeOnly" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=TimeSpan" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=enum" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=flags-enum" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=Uri" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=NodaTime.Instant" - ], - [ - "json.Converters=own-for-value-object", - "shape.wrapped=own-record-class" - ] - ] - }, - { - "reason": "Not promised (own-converter-for-the-wrapped-type): ASP.NET cannot see through a converter of the application's own for the wrapped type and drops what it describes, so ASP.NET's schema for the wrapped type is no reference; the probe notes schema:converter-not-described.", - "pairs": [ - [ - "http.binding=route", - "json.Converters=own-for-wrapped" - ], - [ - "http.binding=query", - "json.Converters=own-for-wrapped" - ], - [ - "http.binding=header", - "json.Converters=own-for-wrapped" - ], - [ - "http.binding=body", - "json.Converters=own-for-wrapped" - ], - [ - "http.binding=form", - "json.Converters=own-for-wrapped" - ], - [ - "http.binding=as-parameters", - "json.Converters=own-for-wrapped" - ], - [ - "http.host=minimal", - "json.Converters=own-for-wrapped" - ], - [ - "http.host=mvc", - "json.Converters=own-for-wrapped" - ], - [ - "http.host=both", - "json.Converters=own-for-wrapped" - ], - [ - "http.host=route-group", - "json.Converters=own-for-wrapped" - ], - [ - "http.host=controller-without-ApiController-visible", - "json.Converters=own-for-wrapped" - ], - [ - "http.nullability=as-declared", - "json.Converters=own-for-wrapped" - ], - [ - "http.nullability=nullable", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=result-to-http-result", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=composed-fault", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=two-faults-same-name", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=group-with-fault-status", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=endpoint-without-faults", - "json.Converters=own-for-wrapped" - ], - [ - "http.response=declared-produces-problem", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=valid", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=refused-by-create", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=wrong-token", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=null", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=empty-string", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=at-type-limit", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=quoted-number", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=escape-sequence", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=duplicate-property", - "json.Converters=own-for-wrapped" - ], - [ - "input.json=metadata-property", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowDuplicateProperties=default", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowDuplicateProperties=false", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowOutOfOrderMetadataProperties=default", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowOutOfOrderMetadataProperties=true", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowTrailingCommas=default", - "json.Converters=own-for-wrapped" - ], - [ - "json.AllowTrailingCommas=true", - "json.Converters=own-for-wrapped" - ], - [ - "json.Converters=own-for-wrapped", - "json.DefaultIgnoreCondition=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.DefaultIgnoreCondition=WhenWritingDefault" - ], - [ - "json.Converters=own-for-wrapped", - "json.DefaultIgnoreCondition=WhenWritingNull" - ], - [ - "json.Converters=own-for-wrapped", - "json.DefaultIgnoreCondition=WhenWriting" - ], - [ - "json.Converters=own-for-wrapped", - "json.DefaultIgnoreCondition=WhenReading" - ], - [ - "json.Converters=own-for-wrapped", - "json.DictionaryKeyPolicy=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.DictionaryKeyPolicy=CamelCase" - ], - [ - "json.Converters=own-for-wrapped", - "json.DictionaryKeyPolicy=SnakeCaseLower" - ], - [ - "json.Converters=own-for-wrapped", - "json.DictionaryKeyPolicy=KebabCaseUpper" - ], - [ - "json.Converters=own-for-wrapped", - "json.DictionaryKeyPolicy=own-upper-case" - ], - [ - "json.Converters=own-for-wrapped", - "json.Encoder=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.Encoder=UnsafeRelaxedJsonEscaping" - ], - [ - "json.Converters=own-for-wrapped", - "json.IgnoreReadOnlyFields=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.IgnoreReadOnlyFields=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.IgnoreReadOnlyProperties=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.IgnoreReadOnlyProperties=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.IncludeFields=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.IncludeFields=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.MaxDepth=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.MaxDepth=3" - ], - [ - "json.Converters=own-for-wrapped", - "json.NumberHandling=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.NumberHandling=AllowReadingFromString" - ], - [ - "json.Converters=own-for-wrapped", - "json.NumberHandling=WriteAsString" - ], - [ - "json.Converters=own-for-wrapped", - "json.NumberHandling=AllowNamedFloatingPointLiterals" - ], - [ - "json.Converters=own-for-wrapped", - "json.PreferredObjectCreationHandling=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.PreferredObjectCreationHandling=Populate" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNameCaseInsensitive=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNameCaseInsensitive=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=CamelCase" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=SnakeCaseLower" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=SnakeCaseUpper" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=KebabCaseLower" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=KebabCaseUpper" - ], - [ - "json.Converters=own-for-wrapped", - "json.PropertyNamingPolicy=own-upper-case" - ], - [ - "json.Converters=own-for-wrapped", - "json.ReadCommentHandling=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.ReadCommentHandling=Skip" - ], - [ - "json.Converters=own-for-wrapped", - "json.ReferenceHandler=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.ReferenceHandler=Preserve" - ], - [ - "json.Converters=own-for-wrapped", - "json.ReferenceHandler=IgnoreCycles" - ], - [ - "json.Converters=own-for-wrapped", - "json.RespectNullableAnnotations=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.RespectNullableAnnotations=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.RespectRequiredConstructorParameters=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.RespectRequiredConstructorParameters=true" - ], - [ - "json.Converters=own-for-wrapped", - "json.TypeInfoResolver=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.TypeInfoResolver=source-generated-context" - ], - [ - "json.Converters=own-for-wrapped", - "json.TypeInfoResolver=default-with-renaming-modifier" - ], - [ - "json.Converters=own-for-wrapped", - "json.TypeInfoResolverChain=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.TypeInfoResolverChain=context-then-default" - ], - [ - "json.Converters=own-for-wrapped", - "json.UnknownTypeHandling=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.UnknownTypeHandling=JsonNode" - ], - [ - "json.Converters=own-for-wrapped", - "json.UnmappedMemberHandling=default" - ], - [ - "json.Converters=own-for-wrapped", - "json.UnmappedMemberHandling=Disallow" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.AllowEmptyInputInBodyModelBinding=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.AllowEmptyInputInBodyModelBinding=true" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.Conventions=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.Conventions=own-route-prefix" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.InputFormatters=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.InputFormatters=xml-added" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxModelBindingCollectionSize=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxModelBindingCollectionSize=2" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxModelValidationErrors=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxModelValidationErrors=1" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxValidationDepth=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.MaxValidationDepth=1" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ModelBinderProviders=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ModelBinderProviders=own-provider-first" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.OutputFormatters=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.OutputFormatters=xml-added" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.RespectBrowserAcceptHeader=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.RespectBrowserAcceptHeader=true" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ReturnHttpNotAcceptable=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ReturnHttpNotAcceptable=true" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ValidateComplexTypesIfChildValidationFails=default" - ], - [ - "json.Converters=own-for-wrapped", - "mvc.ValidateComplexTypesIfChildValidationFails=true" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.CreateSchemaReferenceId=default" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.CreateSchemaReferenceId=own-prefixing" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.CreateSchemaReferenceId=null-inlines-all" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.OpenApiVersion=default" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.OpenApiVersion=OpenApi2_0" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.OpenApiVersion=OpenApi3_0" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.transformers=default" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.transformers=own-schema-transformer" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.transformers=own-operation-transformer" - ], - [ - "json.Converters=own-for-wrapped", - "openapi.transformers=own-document-transformer" - ], - [ - "json.Converters=own-for-wrapped", - "process.converter-lookup=none" - ], - [ - "json.Converters=own-for-wrapped", - "process.converter-lookup=wrapped-type-converter-looked-up-first" - ], - [ - "json.Converters=own-for-wrapped", - "shape.containing=class" - ], - [ - "json.Converters=own-for-wrapped", - "shape.containing=record-class" - ], - [ - "json.Converters=own-for-wrapped", - "shape.containing=struct" - ], - [ - "json.Converters=own-for-wrapped", - "shape.containing=record-struct" - ], - [ - "json.Converters=own-for-wrapped", - "shape.containing=generic-type" - ], - [ - "json.Converters=own-for-wrapped", - "shape.kind=record-struct" - ], - [ - "json.Converters=own-for-wrapped", - "shape.kind=sealed-record-class" - ], - [ - "json.Converters=own-for-wrapped", - "shape.kind=validated-struct" - ], - [ - "json.Converters=own-for-wrapped", - "shape.kind=validated-class" - ], - [ - "json.Converters=own-for-wrapped", - "shape.kind=closed-set" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=root" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=property" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=nullable-property" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=field" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=list-element" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=array-element" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=immutable-array-element" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=read-only-list-element" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=dictionary-key" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=dictionary-value" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=record-ctor-parameter" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=required-init" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=in-patch" - ], - [ - "json.Converters=own-for-wrapped", - "shape.position=polymorphic-derived-property" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=string" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=Guid" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=int" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=long" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=byte" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=ulong" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=decimal" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=double" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=bool" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=char" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=DateTime" - ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=DateTimeOffset" + "sentence": "The JSON, the database column and the OpenAPI schema are the same as the underlying type's.", + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "not-promised:own-converter-for-the-value-object" + }, + { + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + }, + "cite": "not-promised:own-converter-for-the-wrapped-type" + } + ] + }, + { + "id": "nullable-admits-null-as-the-wrapped-does", + "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", + "axes": { + "http.nullability": [ + "nullable" + ] + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "not-promised:own-converter-for-the-value-object" + }, + { + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + }, + "cite": "not-promised:own-converter-for-the-wrapped-type" + } + ] + }, + { + "id": "container-elements", + "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", + "axes": { + "shape.position": [ + "list-element", + "array-element", + "immutable-array-element", + "read-only-list-element", + "dictionary-value" + ] + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "not-promised:own-converter-for-the-value-object" + }, + { + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + }, + "cite": "not-promised:own-converter-for-the-wrapped-type" + } + ] + }, + { + "id": "dictionary-key", + "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", + "axes": { + "shape.position": [ + "dictionary-key" + ] + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "not-promised:own-converter-for-the-value-object" + }, + { + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + }, + "cite": "not-promised:own-converter-for-the-wrapped-type" + } + ] + }, + { + "id": "every-document-version", + "sentence": "Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type.", + "axes": { + "openapi.OpenApiVersion": [ + "OpenApi2_0", + "OpenApi3_0" + ] + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "not-promised:own-converter-for-the-value-object" + }, + { + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + }, + "cite": "not-promised:own-converter-for-the-wrapped-type" + } + ] + } + ], + "not-promised": [ + { + "id": "a-closed-set-is-a-string-enum", + "source": "README.md#what-you-get", + "sentence": "One line per member gives you parsing by name, JSON as the name, an OpenAPI string enum and a text column.", + "axes": { + "shape.kind": [ + "closed-set" + ] + } + }, + { + "id": "own-converter-for-the-value-object", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "sentence": "That converter then writes it instead of the generated one, so it's described as that converter says (see \"A described type\" below).", + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + } + }, + { + "id": "own-converter-for-the-wrapped-type", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "sentence": "ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a dictionary's `additionalProperties` of that type.", + "axes": { + "json.Converters": [ + "own-for-wrapped" + ] + } + }, + { + "id": "a-class-value-object-admits-null", + "source": "design/adr/0036-nullable-value-object-admits-null-openapi.md#0036-a-nullable-value-object-admits-null-in-the-openapi-document-wherever-it-appears", + "sentence": "A nullable value object admits null in the OpenAPI document wherever it appears", + "axes": { + "shape.kind": [ + "sealed-record-class", + "validated-class" + ] + } + }, + { + "id": "openapi-2-nullable-component-loses-its-type", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "sentence": "So under 2.0 a nullable value-object property loses its type, where the underlying type's nullable property keeps it.", + "axes": { + "openapi.OpenApiVersion": [ + "OpenApi2_0" ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=DateOnly" + "openapi.CreateSchemaReferenceId": [ + "default", + "own-prefixing" + ] + } + }, + { + "id": "enum-component-is-its-first-use", + "source": "design/knowledge/KB-027-an-enum-component-is-its-first-use.md#kb-027-the-component-aspnet-makes-for-an-enum-written-by-name-is-the-schema-of-the-first-use-it-meets", + "sentence": "So whether a parameter of the wrapped `E?` admits null in a document with components depends on the endpoints and their order, not on the parameter.", + "axes": { + "json.Converters": [ + "JsonStringEnumConverter" ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=TimeOnly" + "http.nullability": [ + "nullable" ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=TimeSpan" + "shape.wrapped": [ + "enum" + ] + } + }, + { + "id": "openapi-2-nullable-body-keeps-only-its-null", + "source": "design/knowledge/KB-001-openapi-2-nullable-body-drops-pattern.md#kb-001-under-openapi-20-aspnet-writes-a-nullable-request-body-as-allof-x-nullable-true-and-drops-its-pattern", + "sentence": "A minimal API writes it the same, and a nullable enum body, which has no `format`, keeps nothing but the null:", + "axes": { + "openapi.OpenApiVersion": [ + "OpenApi2_0" ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=enum" + "http.binding": [ + "body" ], - [ - "json.Converters=own-for-wrapped", - "shape.wrapped=flags-enum" + "http.nullability": [ + "nullable" ], + "shape.kind": [ + "sealed-record-class", + "validated-class" + ] + } + } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ [ - "json.Converters=own-for-wrapped", - "shape.wrapped=Uri" + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" ], [ - "json.Converters=own-for-wrapped", - "shape.wrapped=NodaTime.Instant" - ], + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + }, + { + "reason": "System.Text.Json refuses adding resolvers to TypeInfoResolverChain once a JsonSerializerContext is the TypeInfoResolver (JsonRow notes it as substituted, STJ refused it), so the strict context never runs with the chain.", + "pairs": [ [ - "json.Converters=own-for-wrapped", - "shape.wrapped=own-record-class" + "json.TypeInfoResolver=source-generated-context", + "json.TypeInfoResolverChain=context-then-default" ] ] }, @@ -14979,6 +13715,19 @@ "dictionary-key" ] } + }, + { + "id": "a-byte-array-is-base64", + "source": "docs/json.md#the-wrapped-types-json", + "sentence": "An array of value objects over `byte` is a JSON array of numbers.", + "axes": { + "shape.wrapped": [ + "byte" + ], + "shape.position": [ + "array-element" + ] + } } ] }, diff --git a/tools/probe/corpus/harness/BaseProbe.cs b/tools/probe/corpus/harness/BaseProbe.cs index b186c97b..13b5e370 100644 --- a/tools/probe/corpus/harness/BaseProbe.cs +++ b/tools/probe/corpus/harness/BaseProbe.cs @@ -1,5 +1,6 @@ // Calibration of the shared HTTP host (not a claim): runs the baseline's wire half over its own rows of claim C07 to find harness mistakes -// before the claims use it. The schema half is claim C21's (C21OpenapiSchemaParityProbe). +// before the claims use it. The schema half is claim C21's (C21OpenapiSchemaParityProbe). Each comparison is a check of its own (HttpBaseline.Run), +// of the implicit obligation claim: C07 promises nothing of the wire, and its seed names no obligation. using Probes.Shapes; namespace Probes; public sealed class BaseProbe diff --git a/tools/probe/corpus/harness/ComposedProbes.cs b/tools/probe/corpus/harness/ComposedProbes.cs index cd04a850..d242b396 100644 --- a/tools/probe/corpus/harness/ComposedProbes.cs +++ b/tools/probe/corpus/harness/ComposedProbes.cs @@ -4,12 +4,14 @@ // set is unmapped". The host maps every fault set with a problem type of its set and kind (TypeOf), so two sets sharing a status name two types. // C05: WithRule(member) is written as `rule`, "qualified with its type's name", a nested type after its nesting chain "whatever QualifiedFaultNames // says"; "The status, the mapping and the document stay the error's"; a value no member names "throws where it's given"; the document describes `rule`. -// Oracle: claim (README text) + differential wire/document. Expected: see Cmp.Expect / Cmp.DocStatuses. +// Oracle: claim (README text) + differential wire/document. Expected: see Cmp.Expect / Cmp.DocStatuses. Each obligation is a check of its +// own (ComposedChecks.C03, C05), every check on one host. using System.Text.Json.Nodes; using CodoMetis.TypeKit; using CodoMetis.TypeKit.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Probes.Faults; using Probes.Shapes; namespace Probes; @@ -33,68 +35,97 @@ public static class ComposedClaims /// The problem type the host maps a fault-set member to: its set's and its kind's, so two sets sharing a status name two types. public static string TypeOf(string set, string kind) => $"https://p.example/{set}/{kind}"; - /// The wire and the document of one endpoint family under the row. e: the error code of Cmp. - public static async Task WireAndDoc(Row r, Entry en, int arity, int e, string rule) + /// The problem's rule, an aspect of its own beside 's. + public const string Rule = "rule"; + + /// + /// The wire and the document of one endpoint family under the row, one check (Row.Check) per obligation names an + /// aspect for ('s and ), against ; every check runs on the one host, with + /// the answers fetched once. e: the error code of Cmp. + /// + public static async Task WireAndDoc(Row r, Entry en, int arity, int e, string rule, Func of, Baseline against) { var variant = Variant(r); - WebApplication app; + var obligations = Aspect.Answer.Append(Rule).Concat(Aspect.Doc).SelectMany(of).Distinct().ToList(); + WebApplication? app = null; Exception? down = null; try { app = await Hx.Start(r, en, Cfg(r)); } - catch (Exception ex) { return "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } + catch (Exception ex) { down = ex; } await using var _ = app; + string? failed = null; + if (down is not null) + { + foreach (var o in obligations) { var f = r.Check(o, against, _ => "host did not start: " + down.GetType().Name + ": " + Cut(down.Message)); failed ??= f; } + return failed; + } var qualified = r.Is("qualified", "QualifiedFaultNames"); var sides = new List(); if (Hx.HasMinimal(r)) sides.Add(Hx.M(r, $"/m/{arity}/{variant}")); if (Hx.HasMvc(r)) sides.Add(Hx.C(r, $"/c/{arity}/{variant}")); - JsonNode? doc = null; string? docFail = null; - try { doc = await Hx.Doc(app); } - catch (Exception ex) - { - var alone = await Hx.DocRefusedByAspNetAlone(r, en, Cfg(r), ex); - if (alone is not null) docFail = alone + " @ " + string.Join(" < ", (ex.StackTrace ?? "").Split('\n').Take(4).Select(x => x.Trim().Split('(')[0])); - } - foreach (var path in sides) + JsonNode? doc = null; Exception? docFailed = null; + try { doc = await Hx.Doc(app!); } + catch (Exception ex) { docFailed = ex; } + var wires = new Dictionary(); + foreach (var path in sides) wires[path] = await Hx.Get(app!, $"{path}?e={e}"); + var (type, member, status) = Cmp.Expect(arity, variant, e); + + foreach (var o in obligations) { - var w = await Hx.Get(app, $"{path}?e={e}"); - var (type, member, status) = Cmp.Expect(arity, variant, e); - if (w.Thrown is not null) return $"{path} e={e}: request threw {w.Thrown}"; - if ((int)w.Status != status) return $"{path} e={e}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; - JsonNode? body; try { body = JsonNode.Parse(w.Body); } catch { return $"{path} e={e}: body is not JSON: {Cut(w.Body)}"; } - var fault = Un(r, body?["fault"]?.GetValue()); - var wantFault = qualified ? $"{type}.{member}" : member; - if (fault != wantFault) return $"{path} e={e}: fault {fault}, expected {wantFault}"; - if (e == 3 && Un(r, body?["detail"]?.GetValue()) != Cmp.Detail) return $"{path} e={e}: detail {body?["detail"]}, expected the occurrence's"; - var gotRule = Un(r, body?["rule"]?.GetValue()); - if (gotRule != (rule == "" ? null : rule)) return $"{path} e={e}: rule {gotRule ?? "(none)"}, expected {(rule == "" ? "(none)" : rule)}"; - // an enum (SFault) is mapped to a status alone, and ASP.NET writes its own default type there - if (Cmp.KindOf(type, member) is { } kind && Un(r, body?["type"]?.GetValue()) is var gotType && gotType != TypeOf(type, kind)) - return $"{path} e={e}: type {gotType ?? "(none)"}, expected {TypeOf(type, kind)}"; - if (int.Parse(body?["status"]?.ToString() is { } st ? Un(r, st)!.Trim('"') : "0") != status) return $"{path} e={e}: problem status field {body?["status"]}"; - // the document - if (docFail is not null) return docFail; - if (doc is null) continue; // ASP.NET alone refuses this document too (noted) - var ops = doc["paths"]?[path]?["get"]; - if (ops is null && !Hx.InDocument(r) && path.Contains("/c/")) { r.Note(NoteKeys.DocControllerNotInApiExplorer); continue; } - if (ops is null) return $"document has no GET {path}"; - var keys = ops["responses"]!.AsObject().Select(kv => kv.Key).Where(k => int.TryParse(k, out var n) && n >= 400).Select(int.Parse).ToList(); - var want = Cmp.DocStatuses(arity, variant); - // A status the endpoint declares itself (the 429) takes its place among the sets' statuses (fixed in 8d1ebd8). - if (!keys.SequenceEqual(want)) return $"{path}: document lists {string.Join(",", keys)}, expected {string.Join(",", want)} (ascending)"; - foreach (var k in keys) + var f = await r.Check(o, against, async c => { - if (variant == "declared" && k == 429) continue; // a declared problem is ASP.NET's, not an answered fault - var txt = Hx.Deep(doc!, ops["responses"]![k.ToString()])?.ToJsonString() ?? ""; - if (!txt.Contains("\"fault\"")) return $"{path} {k}: response schema does not describe fault: {Cut(txt)}"; - if (!txt.Contains("\"rule\"")) return $"{path} {k}: response schema does not describe rule: {Cut(txt)}"; - var desc = ops["responses"]![k.ToString()]?["description"]?.GetValue() ?? ""; - const string Lead = "Problem types: "; - var at = desc.IndexOf(Lead, StringComparison.Ordinal); - var types = at < 0 ? [] : desc[(at + Lead.Length)..].Split(", ").ToHashSet(); - var wantTypes = Cmp.TypesAt(arity, variant, k); - if (!types.SetEquals(wantTypes)) - return $"{path} {k}: description \"{desc}\" names problem types {{{string.Join(", ", types)}}}, expected {{{string.Join(", ", wantTypes)}}}"; - } + bool want(string aspect) => of(aspect).Contains(o); + if (Aspect.Answer.Any(want) || want(Rule)) + foreach (var path in sides) + { + var w = wires[path]; + if (w.Thrown is not null) return $"{path} e={e}: request threw {w.Thrown}"; + if (want(Aspect.Status) && (int)w.Status != status) return $"{path} e={e}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; + JsonNode? body; try { body = JsonNode.Parse(w.Body); } catch { return $"{path} e={e}: body is not JSON: {Cut(w.Body)}"; } + var fault = Un(r, body?["fault"]?.GetValue()); + var wantFault = qualified ? $"{type}.{member}" : member; + if (want(Aspect.Fault) && fault != wantFault) return $"{path} e={e}: fault {fault}, expected {wantFault}"; + if (want(Aspect.Detail) && e == 3 && Un(r, body?["detail"]?.GetValue()) != Cmp.Detail) return $"{path} e={e}: detail {body?["detail"]}, expected the occurrence's"; + var gotRule = Un(r, body?["rule"]?.GetValue()); + if (want(Rule) && gotRule != (rule == "" ? null : rule)) return $"{path} e={e}: rule {gotRule ?? "(none)"}, expected {(rule == "" ? "(none)" : rule)}"; + // an enum (SFault) is mapped to a status alone, and ASP.NET writes its own default type there + if (want(Aspect.Type) && Cmp.KindOf(type, member) is { } kind && Un(r, body?["type"]?.GetValue()) is var gotType && gotType != TypeOf(type, kind)) + return $"{path} e={e}: type {gotType ?? "(none)"}, expected {TypeOf(type, kind)}"; + if (want(Aspect.Status) && int.Parse(body?["status"]?.ToString() is { } st ? Un(r, st)!.Trim('"') : "0") != status) return $"{path} e={e}: problem status field {body?["status"]}"; + } + if (!Aspect.Doc.Any(want)) return null; + // the document: one ASP.NET alone refuses the same way has nothing to check (noted there) + if (docFailed is not null) + return await Hx.DocRefusedByAspNetAlone(r, en, Cfg(r), docFailed) is { } alone + ? alone + " @ " + string.Join(" < ", (docFailed.StackTrace ?? "").Split('\n').Take(4).Select(x => x.Trim().Split('(')[0])) + : null; + foreach (var path in sides) + { + var ops = doc!["paths"]?[path]?["get"]; + if (ops is null && !Hx.InDocument(r) && path.Contains("/c/")) { c.Note(NoteKeys.DocControllerNotInApiExplorer); continue; } + if (ops is null) return $"document has no GET {path}"; + var keys = ops["responses"]!.AsObject().Select(kv => kv.Key).Where(k => int.TryParse(k, out var n) && n >= 400).Select(int.Parse).ToList(); + var listed = Cmp.DocStatuses(arity, variant); + // A status the endpoint declares itself (the 429) takes its place among the sets' statuses (fixed in 8d1ebd8). + if ((want(Aspect.DocListed) || want(Aspect.DocOrder)) && !keys.SequenceEqual(listed)) return $"{path}: document lists {string.Join(",", keys)}, expected {string.Join(",", listed)} (ascending)"; + foreach (var k in keys) + { + if (variant == "declared" && k == 429) continue; // a declared problem is ASP.NET's, not an answered fault + var txt = Hx.Deep(doc!, ops["responses"]![k.ToString()])?.ToJsonString() ?? ""; + if (want(Aspect.DocDescribes) && !txt.Contains("\"fault\"")) return $"{path} {k}: response schema does not describe fault: {Cut(txt)}"; + if (want(Aspect.DocDescribes) && !txt.Contains("\"rule\"")) return $"{path} {k}: response schema does not describe rule: {Cut(txt)}"; + var desc = ops["responses"]![k.ToString()]?["description"]?.GetValue() ?? ""; + const string Lead = "Problem types: "; + var at = desc.IndexOf(Lead, StringComparison.Ordinal); + var types = at < 0 ? [] : desc[(at + Lead.Length)..].Split(", ").ToHashSet(); + var wantTypes = Cmp.TypesAt(arity, variant, k); + if (want(Aspect.DocTypes) && !types.SetEquals(wantTypes)) + return $"{path} {k}: description \"{desc}\" names problem types {{{string.Join(", ", types)}}}, expected {{{string.Join(", ", wantTypes)}}}"; + } + } + return null; + }); + failed ??= f; } - return null; + return failed; } /// "The host doesn't start while either set is unmapped." @@ -125,26 +156,57 @@ public static class ComposedClaims public static class ComposedChecks { static void Process(Row r, Entry en) { if (r.Is("process.converter-lookup", "wrapped-type-converter-looked-up-first")) _ = System.ComponentModel.TypeDescriptor.GetConverter(Make.Held(en.Plain)); } + /// + /// C03, a check per obligation: each-error-own-set (status, fault, problem type, and the rule where the error names none), + /// detail-and-rule-intact (error with-detail or with-rule: the detail and the rule), statuses-ascending (the statuses each operation + /// lists, in order, and their problem schemas), shared-status-types (the problem types each status's description names), and + /// unmapped-set-stops-start (a host with one set unmapped, against the refusal) in every row. + /// public static async Task C03(Row r) { var en = Registry.Of(r); Process(r, en); var arity = int.Parse(r["arity"]); var e = r["error"] switch { "first-set" => 1, "last-set" => 2, "with-detail" => 3, _ => 4 }; - return await ComposedClaims.WireAndDoc(r, en, arity, e, e == 4 ? "PRule.A" : "") ?? await ComposedClaims.UnmappedDoesNotStart(r, en, arity, e); + var intact = r["error"] is "with-detail" or "with-rule"; + string[] Of(string aspect) => aspect switch + { + Aspect.Detail or ComposedClaims.Rule when intact => ["detail-and-rule-intact"], + Aspect.DocListed or Aspect.DocOrder or Aspect.DocDescribes => ["statuses-ascending"], + Aspect.DocTypes => ["shared-status-types"], + _ => ["each-error-own-set"], + }; + var answered = await ComposedClaims.WireAndDoc(r, en, arity, e, e == 4 ? "PRule.A" : "", Of, + Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#faults-of-several-types")); + var unmapped = await r.Check("unmapped-set-stops-start", Baseline.Refusal, _ => ComposedClaims.UnmappedDoesNotStart(r, en, arity, e)); + return answered ?? unmapped; } + /// + /// C05, a check per obligation: unnamed-value-throws (ruletype flags-combination or undefined-cast, against the refusal); + /// rule-qualified (enum, closed-set) or nested-chain (nested-enum), and no-namespace, for the rule on the wire; stays-the-errors for + /// the status, fault, problem type and the document, and in the rows of an unnamed value for the error answered without a rule. + /// public static async Task C05(Row r) { var en = Registry.Of(r); Process(r, en); + var ruletype = r["ruletype"]; // a value no member names throws where it is given (never a rule on the wire) - switch (r["ruletype"]) + var thrown = ruletype switch { - case "flags-combination": - try { _ = Fault.Of(HFault.Clash).WithRule((FRule)3); return "WithRule(flags combination) did not throw"; } catch (ArgumentException) { break; } - case "undefined-cast": - try { _ = Fault.Of(HFault.Clash).WithRule((PRule)9); return "WithRule(undefined cast) did not throw"; } catch (ArgumentException) { break; } - } - var (e, rule) = r["ruletype"] switch { "enum" => (4, "PRule.A"), "closed-set" => (5, "RuleSet.X"), "nested-enum" => (6, "Holder.NestedRule.N"), _ => (1, "") }; - return await ComposedClaims.WireAndDoc(r, en, 3, e, rule); + "flags-combination" => r.Check("unnamed-value-throws", Baseline.Refusal, c => + { + try { _ = Fault.Of(HFault.Clash).WithRule((FRule)3); return "WithRule(flags combination) did not throw"; } catch (ArgumentException) { return null; } + }), + "undefined-cast" => r.Check("unnamed-value-throws", Baseline.Refusal, c => + { + try { _ = Fault.Of(HFault.Clash).WithRule((PRule)9); return "WithRule(undefined cast) did not throw"; } catch (ArgumentException) { return null; } + }), + _ => null, + }; + var (e, rule) = ruletype switch { "enum" => (4, "PRule.A"), "closed-set" => (5, "RuleSet.X"), "nested-enum" => (6, "Holder.NestedRule.N"), _ => (1, "") }; + var named = ruletype switch { "enum" or "closed-set" => "rule-qualified", "nested-enum" => "nested-chain", _ => null }; + string[] Of(string aspect) => aspect == ComposedClaims.Rule && named is not null ? [named, "no-namespace"] : ["stays-the-errors"]; + var answered = await ComposedClaims.WireAndDoc(r, en, 3, e, rule, Of, Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#the-rule-that-refused")); + return thrown ?? answered; } } public static class ComposedShrinkInit diff --git a/tools/probe/corpus/harness/FaultClaim.cs b/tools/probe/corpus/harness/FaultClaim.cs index 99e5662a..73e2bf47 100644 --- a/tools/probe/corpus/harness/FaultClaim.cs +++ b/tools/probe/corpus/harness/FaultClaim.cs @@ -12,6 +12,9 @@ // responses ascending; each fault status's problem schema describes fault and rule, and its description names exactly the problem types // of the members answered with it ("names its problem types", CHANGELOG 2.1.0); // - a status that every endpoint of its scope leaves out stops the host, naming the member and the status. +// Each comparison is about one Aspect, and a claim's Spec.Of names the obligations it is checked for: the row runs one check (Row.Check) per +// obligation it reaches, against the README's rules (Spec.Against) or, for a refusal, the documented one (Spec.Refusals), every check on the +// one host and the answers fetched once. // http.response picks what every endpoint of the row answers (one fault type, a composed fault, two types writing one name) and what the // outermost scope adds (a status for another member, a member left out, a problem the endpoint declares itself). using System.Text.Json.Nodes; @@ -82,9 +85,32 @@ public HashSet TypesAt(int status) => [.. FxErr.Answered(T).Where(m => !LeftOut(m) && StatusOf(m) == status).Select(m => Map(m).Type).OfType()]; } +/// What one comparison of is about; a claim's names the obligations each is checked for. +public static class Aspect +{ + /// The host starts, or refuses to as the README says. + public const string Start = "start"; + /// Reporting a member left out throws, naming the endpoint and the member. + public const string LeftOut = "left-out"; + /// A member's status, on the wire and in the problem's status field. + public const string Status = "status"; + /// The problem's fault, its type and its detail. + public const string Fault = "fault", Type = "type", Detail = "detail"; + /// The operation's statuses (which), their order, the problem schema's fault and rule, and the problem types its description names. + public const string DocListed = "doc-listed", DocOrder = "doc-order", DocDescribes = "doc-describes", DocTypes = "doc-types"; + public static readonly string[] Answer = [Status, Fault, Type, Detail]; + public static readonly string[] Doc = [DocListed, DocOrder, DocDescribes, DocTypes]; +} + /// What one claim's row asks: see the header. public sealed class Spec { + /// The obligations a comparison of an aspect is checked for, given the member it is about (none for the start and the document). + public Func Of = (_, _) => ["claim"]; + /// What the README's rules are compared as: the sentence of the claim's source. + public Baseline Against = Baseline.Model; + /// The obligations whose check holds a refusal by its type and what it names. + public string[] Refusals = []; public MGrp? Minimal; // under the outer group /m, where the host has minimal APIs public string[] Controllers = []; // FxCtl keys, where it has MVC public Decl[] Builder = []; // added to MapControllers()'s builder, after the response's own @@ -226,42 +252,76 @@ static Row StrictContext(Row r) var resp = Resp.Of(r); var (eps, cfg) = Plan(r, s, resp); if (eps.Count == 0) return "harness: the row maps no endpoint of the claim (an excluded pair?)"; - WebApplication app; + WebApplication? app = null; Exception? down = null; try { app = await Hx.Start(r, en, cfg); } - catch (Exception ex) + catch (Exception ex) { down = ex; } + await using var _ = app; + Baseline Against(string o) => s.Refusals.Contains(o) ? Baseline.Refusal : s.Against; + string? failed = null; + + // A host the README says must not start: that is the row's one comparison. + if (s.Refused is not null || s.RefusedAs is not null) { - if (s.RefusedAs is { } judge) return judge(ex); - if (s.Refused is not { } names) return "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); - var ioe = ex as InvalidOperationException ?? ex.InnerException as InvalidOperationException; - if (ioe is null) return $"host refused to start, but with {ex.GetType().Name}, not InvalidOperationException: {Cut(ex.Message)}"; - var missing = names.Where(n => !ioe.Message.Contains(n, StringComparison.Ordinal)).ToList(); - return missing.Count == 0 ? null : $"host refused to start, but not naming {string.Join(", ", missing)}: {Cut(ioe.Message)}"; + foreach (var o in s.Of(Aspect.Start, null)) + { + var f = r.Check(o, Against(o), _ => down is null + ? (s.RefusedAs is not null ? "host started, though the README says this set stops the start" + : $"host started, though a status every endpoint of its scope leaves out must stop it ({string.Join(", ", s.Refused!)})") + : StartRefused(s, down)); + failed ??= f; + } + return failed; + } + + var members = s.Errors.Select(e => FxErr.H[e - 1]).ToList(); + var obligations = Aspect.Answer.Append(Aspect.LeftOut).SelectMany(a => members.SelectMany(m => s.Of(a, m))) + .Concat(Aspect.Doc.SelectMany(a => s.Of(a, null))).Distinct().ToList(); + if (down is not null) + { + foreach (var o in obligations) { var f = r.Check(o, Against(o), _ => "host did not start: " + down.GetType().Name + ": " + Cut(down.Message)); failed ??= f; } + return failed; } - await using var _ = app; - if (s.RefusedAs is not null) return "host started, though the README says this set stops the start"; - if (s.Refused is { } refused) return $"host started, though a status every endpoint of its scope leaves out must stop it ({string.Join(", ", refused)})"; - var display = app.Services.GetRequiredService().Endpoints.OfType() + var display = app!.Services.GetRequiredService().Endpoints.OfType() .GroupBy(e => "/" + (e.RoutePattern.RawText ?? "").TrimStart('/'), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First().DisplayName ?? "", StringComparer.OrdinalIgnoreCase); - - JsonNode? doc = null; string? docFail = null; + JsonNode? doc = null; Exception? docFailed = null; try { doc = await Hx.Doc(app); } - catch (Exception ex) { docFail = await DocRefusedByAspNetAlone(r, en, cfg, ex); } - if (docFail is not null) return docFail; - + catch (Exception ex) { docFailed = ex; } + var wires = new Dictionary<(string Url, int E), Hx.Wire>(); foreach (var ep in eps) - { foreach (var e in s.Errors) + wires[(ep.Url, e)] = await Hx.Get(app, $"{ep.Url}?e={e}"); + + foreach (var o in obligations) + { + var f = await r.Check(o, Against(o), async c => { - var fail = await Wire(r, app, ep, FxErr.H[e - 1], e, display); - if (fail is not null) return fail; - } - if (doc is null) continue; // ASP.NET alone refuses this document too (noted) - var f = DocOf(r, doc, ep); - if (f is not null) return f; + bool want(string aspect, Mem? m) => s.Of(aspect, m).Contains(o); + foreach (var ep in eps) + foreach (var e in s.Errors) + if (Wire(r, ep, FxErr.H[e - 1], wires[(ep.Url, e)], display, want) is { } fail) return fail; + if (!Aspect.Doc.Any(a => want(a, null))) return null; + // A document ASP.NET alone refuses too has nothing to check (noted there); any other failure is returned. + if (docFailed is not null) return await DocRefusedByAspNetAlone(c, r, en, cfg, docFailed); + foreach (var ep in eps) + if (DocOf(r, doc!, ep, want) is { } fail) return fail; + return null; + }); + failed ??= f; } - return null; + return failed; + } + + /// The host refused to start: as the README says, with an InvalidOperationException naming each of , or as judges. + static string? StartRefused(Spec s, Exception ex) + { + if (s.RefusedAs is { } judge) return judge(ex); + var names = s.Refused!; + var ioe = ex as InvalidOperationException ?? ex.InnerException as InvalidOperationException; + if (ioe is null) return $"host refused to start, but with {ex.GetType().Name}, not InvalidOperationException: {Cut(ex.Message)}"; + var missing = names.Where(n => !ioe.Message.Contains(n, StringComparison.Ordinal)).ToList(); + return missing.Count == 0 ? null : $"host refused to start, but not naming {string.Join(", ", missing)}: {Cut(ioe.Message)}"; } /// @@ -270,7 +330,7 @@ static Row StrictContext(Row r) /// (README "A Result as a response"), so ASP.NET alone writing a document of the same host without any problem response says nothing about /// them: under json.MaxDepth=3 System.Text.Json refuses the problem schema of ASP.NET's own ProducesProblem too (decision 99). /// - static async Task DocRefusedByAspNetAlone(Row r, Entry en, HostCfg cfg, Exception withTypeKit) + static async Task DocRefusedByAspNetAlone(Check c, Row r, Entry en, HostCfg cfg, Exception withTypeKit) { static string Say(Exception x) => x.GetType().Name + ": " + Cut(x.Message); var alone = cfg.Copy(); alone.TypeKitOpenApi = false; @@ -289,9 +349,9 @@ static Row StrictContext(Row r) catch (Exception ex) when (r.Is("json.MaxDepth", "3") && Depth(ex) && Depth(withTypeKit)) { // Both refuse the problem schema under MaxDepth=3, by System.Text.Json's own depth checks, but on different paths (TypeKit's - // transformer through the schema exporter, ProducesProblem through the document writer): a refusal of STJ's own, nothing to check. - r.Note(NoteKeys.StjMaxdepth, $"{Say(withTypeKit)} / {Say(ex)}"); - return null; + // transformer through the schema exporter, ProducesProblem through the document writer): a refusal of STJ's own, nothing to check + // (decision 99: under MaxDepth=3 System.Text.Json refuses the OpenAPI document of a problem response for plain ASP.NET too). + return c.Refused(Cite.Record("0099"), $"{Say(withTypeKit)} / {Say(ex)}"); } catch (Exception ex) { return $"document: AddTypeKit() fails ({Say(withTypeKit)}), ASP.NET alone with a ProducesProblem fails otherwise ({Say(ex)})"; } return $"document: AddTypeKit() fails ({Say(withTypeKit)}), ASP.NET alone with a ProducesProblem writes it"; @@ -299,12 +359,13 @@ static Row StrictContext(Row r) static bool Depth(Exception x) => x is InvalidOperationException or System.Text.Json.JsonException; - static async Task Wire(Row r, WebApplication app, Ep ep, Mem m, int e, Dictionary display) + /// One endpoint's answer to reporting , judged on the aspects names. + static string? Wire(Row r, Ep ep, Mem m, Hx.Wire w, Dictionary display, Func want) { - var w = await Hx.Get(app, $"{ep.Url}?e={e}"); var at = $"{ep.Url} reporting {m.Q}"; if (ep.LeftOut(m)) { + if (!want(Aspect.LeftOut, m)) return null; if (w.Thrown is null) return $"{at}: left out of its document, but answered {(int)w.Status}: {Cut(w.Body)}"; if (!w.Thrown.StartsWith(nameof(InvalidOperationException) + ":", StringComparison.Ordinal)) return $"{at}: left out, threw {Cut(w.Thrown)}, expected an InvalidOperationException"; if (!display.TryGetValue(ep.Url, out var name) || name.Length == 0) return $"harness: no endpoint named at {ep.Url}"; @@ -312,21 +373,23 @@ static Row StrictContext(Row r) return $"{at}: the refusal does not name the endpoint ({name}) and the member ({m.Q}): {Cut(w.Thrown)}"; return null; } + if (!Aspect.Answer.Any(a => want(a, m))) return null; if (w.Thrown is not null) return $"{at}: request threw {Cut(w.Thrown)}"; var status = ep.StatusOf(m); - if ((int)w.Status != status) return $"{at}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; + if (want(Aspect.Status, m) && (int)w.Status != status) return $"{at}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; JsonNode? body; try { body = JsonNode.Parse(w.Body); } catch { return $"{at}: body is not JSON: {Cut(w.Body)}"; } var fault = Un(r, body?["fault"]?.GetValue()); - if (fault != m.Name) return $"{at}: fault {fault ?? "(none)"}, expected {m.Name}"; - if (int.Parse(body?["status"]?.ToString() is { } st ? Un(r, st)!.Trim('"') : "0") != status) return $"{at}: problem status field {body?["status"]}, expected {status}"; - if (ep.Map(m).Type is { } type && Un(r, body?["type"]?.GetValue()) != type) return $"{at}: type {body?["type"]}, expected {type}"; + if (want(Aspect.Fault, m) && fault != m.Name) return $"{at}: fault {fault ?? "(none)"}, expected {m.Name}"; + if (want(Aspect.Status, m) && int.Parse(body?["status"]?.ToString() is { } st ? Un(r, st)!.Trim('"') : "0") != status) return $"{at}: problem status field {body?["status"]}, expected {status}"; + if (want(Aspect.Type, m) && ep.Map(m).Type is { } type && Un(r, body?["type"]?.GetValue()) != type) return $"{at}: type {body?["type"]}, expected {type}"; var detail = Un(r, body?["detail"]?.GetValue()); var wantDetail = m.Q == "HFault.Missing" ? "Missing." : null; - if (detail != wantDetail) return $"{at}: detail {detail ?? "(none)"}, expected {wantDetail ?? "none (the member has no description)"}"; + if (want(Aspect.Detail, m) && detail != wantDetail) return $"{at}: detail {detail ?? "(none)"}, expected {wantDetail ?? "none (the member has no description)"}"; return null; } - static string? DocOf(Row r, JsonNode doc, Ep ep) + /// One endpoint's operation in the document, judged on the aspects names. + static string? DocOf(Row r, JsonNode doc, Ep ep, Func want) { var op = doc["paths"]?[ep.Url]?["get"]; if (op is null && ep.Mvc && !Hx.InDocument(r)) { r.Note(NoteKeys.DocControllerNotInApiExplorer); return null; } @@ -334,22 +397,23 @@ static Row StrictContext(Row r) var responses = op["responses"]?.AsObject(); if (responses is null) return $"{ep.Url}: operation has no responses"; var nums = responses.Select(kv => kv.Key).Where(k => int.TryParse(k, out _)).Select(int.Parse).ToList(); - if (!nums.SequenceEqual(nums.Order())) return $"{ep.Url}: responses {string.Join(",", responses.Select(kv => kv.Key))} are not in ascending order"; + if (want(Aspect.DocOrder, null) && !nums.SequenceEqual(nums.Order())) return $"{ep.Url}: responses {string.Join(",", responses.Select(kv => kv.Key))} are not in ascending order"; var got = nums.Where(n => n >= 400).ToList(); - var want = ep.DocStatuses(); - if (!got.SequenceEqual(want)) return $"{ep.Url}: document lists {string.Join(",", got)}, expected {string.Join(",", want)} ({string.Join(" ", ep.Decls)})"; + var listed = ep.DocStatuses(); + // which statuses are listed, as a set: their order is DocOrder's, above + if (want(Aspect.DocListed, null) && !got.Order().SequenceEqual(listed)) return $"{ep.Url}: document lists {string.Join(",", got)}, expected {string.Join(",", listed)} ({string.Join(" ", ep.Decls)})"; foreach (var status in ep.FaultStatuses()) { var resp = responses[status.ToString()]; var txt = Hx.Deep(doc, resp)?.ToJsonString() ?? ""; - if (!txt.Contains("\"fault\"")) return $"{ep.Url} {status}: response schema does not describe fault: {Cut(txt)}"; - if (!txt.Contains("\"rule\"")) return $"{ep.Url} {status}: response schema does not describe rule: {Cut(txt)}"; + if (want(Aspect.DocDescribes, null) && !txt.Contains("\"fault\"")) return $"{ep.Url} {status}: response schema does not describe fault: {Cut(txt)}"; + if (want(Aspect.DocDescribes, null) && !txt.Contains("\"rule\"")) return $"{ep.Url} {status}: response schema does not describe rule: {Cut(txt)}"; var desc = resp?["description"]?.GetValue() ?? ""; const string Lead = "Problem types: "; var i = desc.IndexOf(Lead, StringComparison.Ordinal); var types = i < 0 ? [] : desc[(i + Lead.Length)..].Split(", ").ToHashSet(); var wantTypes = ep.TypesAt(status); - if (!types.SetEquals(wantTypes)) + if (want(Aspect.DocTypes, null) && !types.SetEquals(wantTypes)) return $"{ep.Url} {status}: description \"{desc}\" names problem types {{{string.Join(", ", types)}}}, expected {{{string.Join(", ", wantTypes)}}}"; } return null; diff --git a/tools/probe/corpus/harness/HttpBaseline.cs b/tools/probe/corpus/harness/HttpBaseline.cs index 3f38a8dc..28b89e49 100644 --- a/tools/probe/corpus/harness/HttpBaseline.cs +++ b/tools/probe/corpus/harness/HttpBaseline.cs @@ -1,8 +1,11 @@ // The baseline every http claim shares: under the row's options, a payload with the value object behaves like the same payload with the wrapped // type (response bytes, request reading, parameter binding, OpenAPI schema), in the hosting models the row's http.host names. -// Oracle: differential (JSON/wire/schema parity). Documented differences are in JsonParity.ReadMayDiffer. +// Oracle: differential (JSON/wire/schema parity), each comparison a check (Row.Check); a difference a document makes intended ends its check +// as that citation (Cite), and one no document makes intended is compared. using System.Net; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System.Text.Json; using System.Text.Json.Nodes; namespace Probes.Shapes; @@ -21,39 +24,91 @@ static bool RefusesItsLimit(Entry e) return new[] { "MaxValue", "MinValue" }.Any(f => t.GetField(f)?.GetValue(null) is { } limit && limit.Equals(refused)); } - static string? Same(Row r, Entry e, string what, Hx.Wire p, Hx.Wire v, string input = "valid") + /// + /// One wire comparison with the twin, inside its check : the wrapped type's answer and the + /// value object's . A difference a document makes intended ends the check as that document's citation; BaseProbe + /// calibrates the host on C07's rows, whose claim promises nothing of the wire, so these cite the sentence itself, not a not-promised + /// entry of C07. + /// + static string? Same(Check c, Row r, Entry e, string what, Hx.Wire p, Hx.Wire v, string input = "valid") { r.Log.AppendLine($"{what}: plain {(int)p.Status} {Cut(p.Body)} {p.Thrown} | vo {(int)v.Status} {Cut(v.Body)} {v.Thrown}"); bool ok(Hx.Wire w) => (int)w.Status is >= 200 and < 300; - if (!ok(p) && !ok(v)) return null; - if (r.Is("json.Converters", "own-for-value-object") && ok(v) && ok(p)) { r.Note(NoteKeys.OwnVoConverter); return null; } + if (!ok(p) && !ok(v)) { c.Note(NoteKeys.Compared, what + ": both refused"); return null; } + // "Your own converter wins": the application's converter writes the value object instead of the generated one. + if (r.Is("json.Converters", "own-for-value-object") && ok(v) && ok(p)) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), what); if (ok(p) && !ok(v)) { - if (JsonParity.ReadMayDiffer(r, e, input, null) || (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class")) { r.Note(NoteKeys.DocumentedDiff, what); return null; } + if (ReadRefusedAsDocumented(c, r, e, input, what)) return null; + // the same: the value object's array reads a JSON array of numbers, never the base64 string the twin's byte[] wrote and reads + if (ByteArray(r, e)) return c.Inapplicable(Cite.Docs("docs/json.md#the-wrapped-types-json"), what); + // Text-matching site, kept in this step: the depth refusal is told by the message's words. Baseline gap: no twin in the row + // shows System.Text.Json charging a converter a level (the three-sided differential, next step), and no record cites it. if (r.Is("json.MaxDepth", "3") && (v.Thrown ?? v.Body).Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "value-object side"); return null; } // A closed set travels as its member's name; the wrapped type under the application's converter is no reference. - if (e.Kind == "closed-set" && r.Is("json.Converters", "own-for-wrapped")) { r.Note(NoteKeys.ClosedSetVsOwnWrappedConverter, what); return null; } + if (e.Kind == "closed-set" && r.Is("json.Converters", "own-for-wrapped")) return c.Inapplicable(Cite.Docs("README.md#what-you-get"), what); // The validated shapes' Create refuses the wrapped type's limit on purpose, so the limit input is a refusal. - if (e.Kind.StartsWith("validated") && input == "at-type-limit" && RefusesItsLimit(e)) { r.Note(NoteKeys.ValidatedRefusesTypeLimit, what); return null; } + if (e.Kind.StartsWith("validated") && input == "at-type-limit" && RefusesItsLimit(e)) return c.Refused(Cite.Docs("docs/json.md#when-a-value-is-rejected"), what); return (e.Wrapped == "NodaTime.Instant" && what.StartsWith("mvc bind") ? "[F1] " : "") + $"{what}: wrapped answered {(int)p.Status} {Cut(p.Body)}, value object answered {(int)v.Status} {Cut(v.Body)} {v.Thrown}"; } if (!ok(p) && ok(v)) { - // README "What TypeKit guarantees": a JSON null is refused by a struct value object; a class value object - // takes it as a reference does, where the struct it wraps refuses it. - if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) { r.Note(NoteKeys.ClassVoTakesNull, what); return null; } + // docs/json.md "Null and missing": a struct value object rejects a JSON null; a sealed partial record reads it as null, + // like any class, where the struct it wraps refuses it. + if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return c.Refused(Cite.Docs("docs/json.md#null-and-missing"), what); + // Text-matching site, kept in this step: as the value-object side's above. Baseline gap: the same. if (r.Is("json.MaxDepth", "3") && (p.Thrown ?? p.Body).Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "wrapped side"); return null; } } if (!ok(p) && ok(v)) return $"{what}: value object answered {(int)v.Status} {Cut(v.Body)} where wrapped answered {(int)p.Status} {Cut(p.Body)} {p.Thrown}"; if (p.Body != v.Body) { - if (e.Kind == "closed-set") { r.Note(NoteKeys.ClosedSetDiffers, what); return null; } - if (e.Wrapped == "byte" && r["shape.position"] == "array-element") { r.Note(NoteKeys.ByteArrayIsBase64, "echo"); return null; } + // A closed set travels as its member's name, never as the string or number standing in for it. + if (e.Kind == "closed-set") return c.Inapplicable(Cite.Docs("README.md#what-you-get"), what); + // docs/json.md "The wrapped type's JSON": an array of value objects over byte is a JSON array of numbers, where the twin, a byte[], + // is one base64 string (KB-038). + if (ByteArray(r, e)) return c.Inapplicable(Cite.Docs("docs/json.md#the-wrapped-types-json"), what); return $"{what}: bodies differ: wrapped {Cut(p.Body)} vs value object {Cut(v.Body)}"; } + c.Note(NoteKeys.Compared, what); return null; } + /// An array of value objects over byte, whose twin is a byte[] (KB-038). + static bool ByteArray(Row r, Entry e) => e.Wrapped == "byte" && r["shape.position"] == "array-element"; + + /// What MVC writes for a type's default: null for no content (a reference, or a Nullable), else its JSON under MVC's options. + static string? DefaultBody(WebApplication app, Type t) => + !t.IsValueType || Nullable.GetUnderlyingType(t) is not null ? null + : JsonSerializer.Serialize(Activator.CreateInstance(t), t, app.Services.GetRequiredService>().Value.JsonSerializerOptions); + + /// + /// Whether an answer is the type's default as MVC writes it: 204 for null, else 200 with its JSON, compared as JSON, since MVC's + /// formatter escapes with its own encoder where the options name none. + /// + static bool HoldsDefault(Hx.Wire w, string? body) + { + if (body is null) return w.Status == HttpStatusCode.NoContent; + if (w.Status != HttpStatusCode.OK) return false; + try { return JsonNode.DeepEquals(JsonNode.Parse(w.Body), JsonNode.Parse(body)); } + catch (JsonException) { return false; } + } + + /// A text the wrapped type does not read, for a twin that shows what MVC does with a binding failure; none where any text binds. + static string? Unreadable(Entry e) => e.Wrapped is "string" or "Uri" or "own-record-class" ? null : "not-a-value"; + + /// + /// The value object refused a read the wrapped type answered, as a document says it does (JsonParity.ReadMayDiffer's reasons, the JSON + /// family's, each cited by its sentence): the check ends as that refusal and true is returned. + /// + static bool ReadRefusedAsDocumented(Check c, Row r, Entry e, string input, string what) + { + if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class") { c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), what); return true; } + if (e.Kind.StartsWith("validated") && input == "refused-by-create" && r.Get("json.Converters") != "StoredJsonConverterFactory") { c.Refused(Cite.Docs("docs/json.md#when-a-value-is-rejected"), what); return true; } + if (input == "null" && (e.Vo.IsValueType || e.Plain.IsValueType)) { c.Refused(Cite.Docs("docs/json.md#null-and-missing"), what); return true; } + if (e.Kind == "closed-set" && input != "valid") { c.Refused(Cite.Docs("README.md#what-you-get"), what); return true; } + return false; + } + /// Where two schemas first differ, and what each side has there: the comparison's 220 characters rarely reach it. static string FirstDifference(JsonNode? p, JsonNode? v, string path) => Difference(p, v, path) is var (at, wp, wv) ? $"{at}: wrapped {Cut(wp?.ToJsonString() ?? "(absent)")} | value object {Cut(wv?.ToJsonString() ?? "(absent)")}" : ""; @@ -74,7 +129,14 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => return (path, p, v); } - public static async Task Run(Row r, Entry e, bool schema = true) + /// + /// BaseProbe's calibration of the shared host: every wire comparison a check of its own, named (BaseProbe + /// runs on rows of C07, which promises nothing of the wire, so its checks are the implicit claim). Per side: the echo against the + /// twin; the request read with the row's input.json, against the twin, or a closed set against its own response read back; the binding of + /// the row's http.binding with a valid value against the twin, and with a value Create refuses against the documented 400. With + /// , the schema parity too (C21's check). Every check runs; the first failure is returned. + /// + public static async Task Run(Row r, Entry e, bool schema = true, string obligation = "claim") { WebApplication app; try { app = await Hx.Start(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }); } @@ -83,6 +145,10 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => var jopts = JsonRow.Apply(r, e).Options!; var position = r["shape.position"]; var plainSample = e.Valid(); + var input = r.Get("input.json") ?? "valid"; + var ownVo = r.Is("json.Converters", "own-for-value-object"); + var bind = r["http.binding"]; + string? failed = null; var sides = new List<(string name, Func path, string echo, string inn, string bindPre)>(); if (Hx.HasMinimal(r)) sides.Add(("minimal", p => Hx.M(r, p), "/echo/", "/in/", "/bind/")); if (Hx.HasMvc(r)) sides.Add(("mvc", p => Hx.C(r, p), "/mvc/echo/", "/mvc/in/", "/mvc/bind/")); @@ -90,90 +156,171 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => { // 1. response side var ep = await Hx.Get(app, path(echo + "plain")); var ev = await Hx.Get(app, path(echo + "vo")); - if (Same(r, e, name + " echo", ep, ev) is { } f1) return f1; - // 2. request side, with the row's input.json - if (e.Wrapped == "byte" && position == "array-element") r.Note(NoteKeys.ByteArrayIsBase64, "in"); - else if ((int)ep.Status == 200) + var f1 = r.Check(obligation, Baseline.Twin, c => Same(c, r, e, name + " echo", ep, ev)); failed ??= f1; + // 2. request side, with the row's input.json, where the wrapped type's echo gave a body to change + if ((int)ep.Status == 200) { - var input = (r.Get("input.json") ?? "valid"); - var text = JsonParity.InputFor(input, e, jopts, ep.Body, position); - var ownText = r.Is("json.Converters", "own-for-value-object") || e.Kind == "closed-set"; // a closed set travels as its name: it reads its own spelling, the string is only a stand-in - var textVo = text; - if (ownText) { r.Note(NoteKeys.OwnSpelling, "own converter: the value object reads its own spelling"); text = ep.Body; textVo = ev.Body; } - var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), text); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), textVo); - if (ownText) { if ((int)ev.Status == 200 && (int)iv.Status != 200 && (int)ip.Status == 200) return $"{name} in: the value object cannot read its own response ({(int)iv.Status}): {Cut(iv.Body)}"; r.Note(NoteKeys.OwnSpelling, "in"); } + string? f2; + if (ownVo) + // "Your own converter wins": the application's converter reads the value object, so the twin's text is none of its; + // only that it reads its own response back is held, and the check earns nothing. + f2 = await r.Check(obligation, Baseline.RoundTrip, async c => + { + var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), ep.Body); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), ev.Body); + if ((int)ev.Status == 200 && (int)iv.Status != 200 && (int)ip.Status == 200) return $"{name} in: the value object cannot read its own response ({(int)iv.Status}): {Cut(iv.Body)}"; + return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), name + " in"); + }); + else if (e.Kind == "closed-set") + // A closed set travels as its member's name (README "What you get"): the string standing in for it is no twin, so the + // value object reads its own response back. + f2 = await r.Check(obligation, Baseline.RoundTrip, async c => + { + if ((int)ev.Status != 200) return c.Inconclusive($"{name} in: the value object's echo answered {(int)ev.Status}, no response of its own to read back"); + var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), ep.Body); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), ev.Body); + r.Log.AppendLine($"{name} in(own response): plain {(int)ip.Status} {Cut(ip.Body)} {ip.Thrown} | vo {(int)iv.Status} {Cut(iv.Body)} {iv.Thrown}"); + // The wrapped type does not read its own response back under the row's options either: nothing to tell. + if ((int)iv.Status != 200 && (int)ip.Status != 200) return c.Inconclusive($"{name} in: neither side reads its own response back ({(int)ip.Status}, {(int)iv.Status})"); + if ((int)iv.Status != 200) return $"{name} in: the closed set cannot read its own response ({(int)iv.Status}), the wrapped type reads its own: {Cut(iv.Body)} {iv.Thrown}"; + c.Note(NoteKeys.Compared, name + " in: its own response read back"); + return null; + }); else - if (Same(r, e, name + " in(" + input + ")", ip, iv, input) is { } f2) return f2; + f2 = await r.Check(obligation, Baseline.Twin, async c => + { + var text = JsonParity.InputFor(input, e, jopts, ep.Body, position); + var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), text); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), text); + return Same(c, r, e, name + " in(" + input + ")", ip, iv, input); + }); + failed ??= f2; } // 3. binding, the row's http.binding - var bind = r["http.binding"]; - if (e.Wrapped == "own-record-class" && bind != "body") { r.Note(NoteKeys.NotBindableFromText, "record class"); bind = "none"; } - if (e.Wrapped == "NodaTime.Instant" && name == "minimal" && bind != "body") { r.Note(NoteKeys.NotBindableFromText, "minimal API, Instant"); bind = "none"; } + var unbindable = e.Wrapped == "own-record-class" && bind != "body" ? "a record class" + : e.Wrapped == "NodaTime.Instant" && name == "minimal" && bind != "body" ? "Instant in a minimal API" : null; + if (unbindable is not null) + { + // No sentence cites it: the wrapped type is not bound from text either, so neither side has an endpoint and there is no twin. + r.Check(obligation, Baseline.Twin, c => c.Inconclusive($"{name} bind {bind}: {unbindable} is not bound from text, the wrapped type neither")); + continue; + } + if (bind == "body" && ownVo) + { + // "Your own converter wins": the application's converter reads the body, not the twin's JSON. + r.Check(obligation, Baseline.Twin, c => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), $"{name} bind body")); + continue; + } var valid = Hx.BindText(e, plainSample); var refused = e.Refused() is { } rf ? Hx.BindText(e, rf) : null; - foreach (var (which, txt) in new[] { ("valid", valid), ("refused", refused) }) + async Task B(string which, string txt, string w) + { + var enc = Uri.EscapeDataString(txt); + return bind switch + { + "route" => await Hx.Get(app, path(bindPre + "route/" + w + "/" + enc)), + "query" => await Hx.Get(app, path(bindPre + "query/" + w + "?v=" + enc)), + "as-parameters" => await Hx.Get(app, path(bindPre + "params/" + w + "?v=" + enc)), + "header" => await Hx.Send(app, HttpMethod.Get, path(bindPre + "header/" + w), header: ("v", txt)), + "form" => await Hx.Send(app, HttpMethod.Post, path(bindPre + "form/" + w), "v=" + enc, "application/x-www-form-urlencoded"), + _ => await Hx.Send(app, HttpMethod.Post, path(bindPre + "body/" + w), + which == "valid" ? JsonSerializer.Serialize(plainSample, e.Plain, jopts) : JsonSerializer.Serialize(e.Refused(), e.Plain, jopts)), + }; + } + // Both sides are mapped for every binding they reach here: a 404 is the harness's routing, never a binding result. Counted as + // "plain unbindable", a plain route mapped under another name went unnoticed. + static string? Routed(string name, string bind, Hx.Wire bp, Hx.Wire bv) => + bp.Status == HttpStatusCode.NotFound || bv.Status == HttpStatusCode.NotFound + ? $"harness: {name} bind {bind} has no endpoint (plain {(int)bp.Status}, value object {(int)bv.Status})" : null; + // A text a route segment cannot carry as it is ('/', ':', '?', '#'): escaped, it arrives as other text ('%2F' stays), so the value + // bound is not the one sent. A limit of the harness, which no sentence cites: the check cannot tell. + bool Unsafe(string txt) => bind == "route" && (txt.Contains('/') || txt.Contains(':') || txt.Contains('?') || txt.Contains('#')); + string? Unplaceable(Check c, string txt) => c.Inconclusive($"{name} bind route: the text {txt} cannot be carried by a route segment as it is"); + var f3 = await r.Check(obligation, Baseline.Twin, async c => + { + if (Unsafe(valid)) return Unplaceable(c, valid); + var bp = await B("valid", valid, "plain"); var bv = await B("valid", valid, "vo"); + if (Routed(name, bind, bp, bv) is { } unrouted) return unrouted; + r.Log.AppendLine($"{name} bind {bind} valid text={valid}: plain {(int)bp.Status} {Cut(bp.Body)} {bp.Thrown} | vo {(int)bv.Status} {Cut(bv.Body)} {bv.Thrown}"); + // The twin refuses the valid text too: no binding to compare, and no sentence cites it. + if ((int)bp.Status is not (>= 200 and < 300) && (int)bv.Status is not (>= 200 and < 300)) + return c.Inconclusive($"{name} bind {bind}: the wrapped type refuses the valid text too ({(int)bp.Status}), the value object answered {(int)bv.Status}"); + return Same(c, r, e, name + " bind " + bind, bp, bv); + }); + failed ??= f3; + if (refused is null) continue; + // README "Binding" (AspNetCore): a value Create rejects gets a 400, the one set of rules. + var f4 = await r.Check(obligation, Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#binding"), async c => { - if (txt is null || bind == "none") continue; - if (bind == "route" && (txt.Contains('/') || txt.Contains(':') || txt.Contains('?') || txt.Contains('#'))) { r.Note(NoteKeys.RouteTextUnsafe); continue; } - async Task B(string w) + if (Unsafe(refused)) return Unplaceable(c, refused); + var bp = await B("refused", refused, "plain"); var bv = await B("refused", refused, "vo"); + if (Routed(name, bind, bp, bv) is { } unrouted) return unrouted; + r.Log.AppendLine($"{name} bind {bind} refused text={refused}: plain {(int)bp.Status} {Cut(bp.Body)} {bp.Thrown} | vo {(int)bv.Status} {Cut(bv.Body)} {bv.Thrown}"); + if ((int)bp.Status is not (>= 200 and < 300)) return c.Inconclusive($"{name} bind {bind}: the wrapped type refuses the value Create refuses too ({(int)bp.Status})"); + if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class") return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "bind"); + // README "What TypeKit guarantees": StoredJsonConverterFactory reads JSON the application stored itself, without validation. + if (bind == "body" && r.Is("json.Converters", "StoredJsonConverterFactory")) return c.Inapplicable(Cite.Docs("README.md#what-typekit-guarantees"), "bind body"); + // KB-037: under MaxValidationDepth=1 MVC runs an action whose parameter it could not bind, with the parameter at its default. The + // value object must hold its default then, never the refused value; the twin, sent a text it cannot read in the same host, shows + // the framework doing the same. + if (name == "mvc" && r.Is("mvc.MaxValidationDepth", "1") && (int)bv.Status is >= 200 and < 300) { - var enc = Uri.EscapeDataString(txt); - return bind switch + if (DefaultBody(app, e.Vo) is var voDefault && !HoldsDefault(bv, voDefault)) + return $"{name} bind {bind}: validated value object bound the refused value (status {(int)bv.Status}) {Cut(bv.Body)}, not its default ({(voDefault is null ? "204" : "200 " + Cut(voDefault))})"; + if (Unreadable(e) is not { } text) + return c.Inapplicable(Cite.Fact("KB-037"), $"{name} bind {bind}: the value object holds its default; any text binds the wrapped type, so no twin shows it"); + var enc = Uri.EscapeDataString(text); + var bt = bind switch { - "route" => await Hx.Get(app, path(bindPre + "route/" + w + "/" + enc)), - "query" => await Hx.Get(app, path(bindPre + "query/" + w + "?v=" + enc)), - "as-parameters" => await Hx.Get(app, path(bindPre + "params/" + w + "?v=" + enc)), - "header" => await Hx.Send(app, HttpMethod.Get, path(bindPre + "header/" + w), header: ("v", txt)), - "form" => await Hx.Send(app, HttpMethod.Post, path(bindPre + "form/" + w), "v=" + enc, "application/x-www-form-urlencoded"), - _ => await Hx.Send(app, HttpMethod.Post, path(bindPre + "body/" + w), - which == "valid" ? JsonSerializer.Serialize(plainSample, e.Plain, jopts) : JsonSerializer.Serialize(e.Refused(), e.Plain, jopts)), + "route" => await Hx.Get(app, path(bindPre + "route/plain/" + enc)), + "query" => await Hx.Get(app, path(bindPre + "query/plain?v=" + enc)), + "as-parameters" => await Hx.Get(app, path(bindPre + "params/plain?v=" + enc)), + "header" => await Hx.Send(app, HttpMethod.Get, path(bindPre + "header/plain"), header: ("v", text)), + "form" => await Hx.Send(app, HttpMethod.Post, path(bindPre + "form/plain"), "v=" + enc, "application/x-www-form-urlencoded"), + _ => await Hx.Send(app, HttpMethod.Post, path(bindPre + "body/plain"), JsonSerializer.Serialize(text)), }; + r.Log.AppendLine($"{name} bind {bind} unreadable text={text}: plain {(int)bt.Status} {Cut(bt.Body)} {bt.Thrown}"); + if (HoldsDefault(bt, DefaultBody(app, e.Plain))) + return c.Inapplicable(Cite.Fact("KB-037"), $"{name} bind {bind}: both hold their default, the wrapped type for a text it cannot read"); + if ((int)bt.Status is >= 400 and < 500) + return $"{name} bind {bind}: the value object answered {(int)bv.Status} holding its default, where the wrapped type rejects a text it cannot read ({(int)bt.Status})"; + // KB-037's class from the query: the validator throws at the twin's property, and the value object, written by a + // converter of its own, is no class it walks + return c.Inconclusive($"{name} bind {bind}: the value object holds its default (KB-037); the wrapped type answered {(int)bt.Status} {Cut(bt.Body)} {bt.Thrown}"); } - if (bind == "body" && r.Is("json.Converters", "own-for-value-object")) { r.Note(NoteKeys.OwnVoBodyBindingSkipped); continue; } - var bp = await B("plain"); var bv = await B("vo"); - // Both sides are mapped for every binding they reach here: a 404 is the harness's routing, never a binding - // result. Counted as "plain unbindable", a plain route mapped under another name went unnoticed. - if (bp.Status == HttpStatusCode.NotFound || bv.Status == HttpStatusCode.NotFound) - return $"harness: {name} bind {bind} has no endpoint (plain {(int)bp.Status}, value object {(int)bv.Status})"; - var plainOk = (int)bp.Status is >= 200 and < 300; - r.Log.AppendLine($"{name} bind {bind} {which} text={txt}: plain {(int)bp.Status} {Cut(bp.Body)} {bp.Thrown} | vo {(int)bv.Status} {Cut(bv.Body)} {bv.Thrown}"); - if (which == "valid") - { - if (!plainOk) { r.Note(NoteKeys.BindPlainUnbindable, name); continue; } - if (Same(r, e, name + " bind " + bind, bp, bv) is { } f3) return f3; - } - else - { - if (!plainOk) continue; - if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class") { r.Note(NoteKeys.PreserveDocumented, "bind"); continue; } - if (name == "mvc" && r.Is("mvc.MaxValidationDepth", "1")) { r.Note(NoteKeys.MvcMaxvalidationdepth1IgnoresBindingErrors); continue; } // MVC itself: any type's binding error is ignored (D2) - if (bind == "body" && r.Is("json.Converters", "StoredJsonConverterFactory")) { r.Note(NoteKeys.StoredFactorySkipsValidation); continue; } // intended - // a refused value must not bind (the one set of rules), except where the application skips validation on purpose - if ((int)bv.Status is >= 200 and < 300) return $"{name} bind {bind}: validated value object bound the refused value (status {(int)bv.Status}) {Cut(bv.Body)}"; - // MVC writes its 400 as ValidationProblemDetails, which a source-generated context alone does not list: - // ASP.NET's 500 for any refused binding under that configuration, the wrapped type's included. - if ((int)bv.Status == 500 && name == "mvc" && r.Is("json.TypeInfoResolver", "source-generated-context") - && (bv.Thrown ?? bv.Body ?? "").Contains("ValidationProblemDetails")) { r.Note(NoteKeys.Mvc400UnwritableUnderSourceGen); continue; } - if ((int)bv.Status != 400) return $"{name} bind {bind}: refused value answered {(int)bv.Status}, expected 400: {Cut(bv.Body)} {bv.Thrown}"; - } - } + // a refused value must not bind (the one set of rules), except where the application skips validation on purpose + if ((int)bv.Status is >= 200 and < 300) return $"{name} bind {bind}: validated value object bound the refused value (status {(int)bv.Status}) {Cut(bv.Body)}"; + // Text-matching site, kept in this step: MVC writes its 400 as ValidationProblemDetails, which a source-generated context alone + // does not list, so ASP.NET answers 500 for any refused binding under that configuration, the wrapped type's included. Baseline + // gap: the message's words tell it, where a wrapped twin refused in the same row should (the three-sided differential). + if ((int)bv.Status == 500 && name == "mvc" && r.Is("json.TypeInfoResolver", "source-generated-context") + && (bv.Thrown ?? bv.Body ?? "").Contains("ValidationProblemDetails")) { r.Note(NoteKeys.Mvc400UnwritableUnderSourceGen); return null; } + if ((int)bv.Status != 400) return $"{name} bind {bind}: refused value answered {(int)bv.Status}, expected 400: {Cut(bv.Body)} {bv.Thrown}"; + c.Note(NoteKeys.Compared, $"{name} bind {bind}: the refused value answered 400"); + return null; + }); + failed ??= f4; } // 4. schema parity - if (schema) { if (await SchemaParity(app, r, e) is { } f4) return f4; } - return null; + if (schema) { var f5 = await r.Check(obligation, Baseline.Twin, c => SchemaParity(app, r, e, c)); failed ??= f5; } + return failed; } /// - /// The schema parity alone, on the same host as : claim C21's check (C21OpenapiSchemaParityProbe), - /// which BaseProbe leaves out of its calibration. + /// The schema parity alone, on the same host as : claim C21's check (C21OpenapiSchemaParityProbe), which BaseProbe leaves + /// out of its calibration, run once for each of the row's against the twin. Every check runs; the first + /// failure is returned. /// - public static async Task Schema(Row r, Entry e) + public static async Task Schema(Row r, Entry e, IEnumerable obligations) { - WebApplication app; + WebApplication? app = null; string? down = null; try { app = await Hx.Start(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }); } - catch (Exception ex) { return "schema host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } + catch (Exception ex) { down = "schema host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } await using var _ = app; - return await SchemaParity(app, r, e); + string? failed = null; + foreach (var obligation in obligations) + { + var f = await r.Check(obligation, Baseline.Twin, c => down is not null ? Task.FromResult(down) : SchemaParity(app!, r, e, c)); + failed ??= f; + } + return failed; } /// @@ -266,7 +413,7 @@ static bool NullOrReference(JsonNode? n) => return (np?.ToJsonString() ?? "null", nv?.ToJsonString() ?? "null", around); } - static async Task SchemaParity(WebApplication app, Row r, Entry e) + static async Task SchemaParity(WebApplication app, Row r, Entry e, Check c) { JsonNode doc; try { doc = (await Hx.Doc(app))!; } @@ -275,8 +422,12 @@ static bool NullOrReference(JsonNode? n) => // The baseline is ASP.NET alone on the same host: a document it refuses too has no schema to compare. return await Hx.DocRefusedByAspNetAlone(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }, ex); } - if (r.Is("json.Converters", "own-for-wrapped")) { r.Note(NoteKeys.SchemaConverterNotDescribed, "own converter for the wrapped type"); return null; } // docs/json.md "Describing what a converter writes": ASP.NET drops items/additionalProperties of a converter it cannot see into, the value object's schema is the underlying type's - if (r.Is("json.Converters", "own-for-value-object")) { r.Note(NoteKeys.SchemaConverterNotDescribed, "own converter for the value object"); return null; } // docs/json.md "Describing what a converter writes": the exporter loses track of a converter it cannot see into + // docs/json.md "Describing what a converter writes": ASP.NET drops items/additionalProperties of a converter it cannot see into, the value + // object's schema is the underlying type's; and the application's converter for the value object describes it as that converter says. + // C21 declares both on every obligation (claims.json inapplicable), so Row.Check records them before this body runs and no row of + // C21 is generated with them; these lines hold for the schema half run under another claim (ShrinkProbe's baseline). + if (r.Is("json.Converters", "own-for-wrapped")) return c.Inapplicable(Cite.NotPromised("own-converter-for-the-wrapped-type")); + if (r.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.NotPromised("own-converter-for-the-value-object")); string? Op(string path, string method, out JsonNode? node) { node = doc["paths"]?[path]?[method]; return node is null ? "missing " + method + " " + path : null; @@ -342,61 +493,71 @@ string Inputs(JsonNode op) // a record class over TimeOnly, optional, against a required TimeOnly). var twinned = pp0.Contains("/bind/") || pp0.Contains("/schema/") && r["shape.position"] == "root"; var pp = e.Vo.IsValueType != e.Plain.IsValueType && Hx.PlainNull(r, e) is not null && twinned ? pp0.Replace("/plain", "/plainnull") : pp0; - if (pp != pp0) r.Note(NoteKeys.SchemaComparedWithNullableWrapped); + if (pp != pp0) c.Note(NoteKeys.SchemaComparedWithNullableWrapped); if (Op(pp, method, out var po) is { } m1) { // Mapped for bodies only (a record class, Instant in a minimal API): neither side is there. if (Op(vp, method, out _) is null) return $"harness: the value object's {method} {vp} is documented, its plain twin is not ({m1})"; - r.Note(NoteKeys.SchemaNeitherSideDocumented, m1); continue; + c.Note(NoteKeys.SchemaNeitherSideDocumented, m1); continue; } if (Op(vp, method, out var vo) is { } m2) return m2; if (Inputs(po!) is var ip && Inputs(vo!) is var iv && ip != iv) { + // No twin of the wrapped type at the value object's nullability here (a struct value object over a class, or under a + // source-generated context an enum or Instant, whose Nullable the probes' context does not list): the two sides are not + // alike, and no sentence cites it, so the check cannot tell. if (e.Vo.IsValueType != e.Plain.IsValueType && Hx.PlainNull(r, e) is null) - r.Note(NoteKeys.SchemaNullabilityNotComparable); // no nullable wrapped endpoint here (an enum or Instant under a source-generated context) + c.Inconclusive($"{pp} vs {vp}: no twin of {e.Plain.Name} at the value object's nullability, the inputs differ: wrapped [{ip}] vs value object [{iv}]"); else r.Outcome("harness-error", $"inputs not equivalent for {pp} vs {vp}: wrapped [{ip}] vs value object [{iv}]"); continue; } var sp = Sig(po!); var sv = Sig(vo!); var written = sp; - if (NullAroundReferences(sp, sv) is var (np, nv, around)) { sp = np; sv = nv; if (around) r.Note(NoteKeys.SchemaNullAroundReferenceNormalised); } + if (NullAroundReferences(sp, sv) is var (np, nv, around)) { sp = np; sv = nv; if (around) c.Note(NoteKeys.SchemaNullAroundReferenceNormalised); } if (sp != sv && pp != pp0) { // Both sides nullable alike; OpenAPI 3.x spells nullability two ways (oneOf [null, X] or type [null, X]). sp = Hx.NoNull(JsonNode.Parse(sp))!.ToJsonString(); sv = Hx.NoNull(JsonNode.Parse(sv))!.ToJsonString(); - r.Note(NoteKeys.SchemaNullableSpellingsNormalised); + c.Note(NoteKeys.SchemaNullableSpellingsNormalised); } if (sp != sv && e.Vo.IsValueType != e.Plain.IsValueType && pp == pp0) { // A class value object inside a declared shape is nullable by being a class (ADR 0036: a nullable // value object admits null in OpenAPI); the markers go, the rest of the schema must agree. sp = Hx.NoNull(JsonNode.Parse(sp))!.ToJsonString(); sv = Hx.NoNull(JsonNode.Parse(sv))!.ToJsonString(); - r.Note(NoteKeys.SchemaClassVoNullabilityNormalised); + c.Note(NoteKeys.SchemaClassVoNullabilityNormalised); } if (sp != sv) { - if (e.Kind == "closed-set") { r.Note(NoteKeys.ClosedSetDiffers, "schema"); continue; } + if (e.Kind == "closed-set") { c.Inapplicable(Cite.NotPromised("a-closed-set-is-a-string-enum"), "schema"); continue; } var (at, wp, wv) = Difference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$"); var openApi2 = r.Is("openapi.OpenApiVersion", "OpenApi2_0"); // KB-026: OpenAPI 2.0 writes a nullable property of any component as allOf [{x-nullable: true}], without the reference, and a // value object keeps a component of its own (not-promised openapi-2-nullable-component-loses-its-type; row 38 of C21). + // Text-matching site, kept in this step: the difference is told by its path's suffix and the value object's side spelled out. + // Baseline gap: no plain nullable component in the same document shows ASP.NET losing its reference too (the three-sided + // differential, next step). if (openApi2 && !r.Is("openapi.CreateSchemaReferenceId", "null-inlines-all") && at.EndsWith(".allOf", StringComparison.Ordinal) && wp is null && wv?.ToJsonString() == """[{"x-nullable":true}]""") - { r.Note(NoteKeys.SchemaOpenApi2NullableComponentLosesType, at); continue; } + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), at); continue; } // KB-001: OpenAPI 2.0 writes a nullable body of the wrapped value type as allOf [{x-nullable: true}] alone, where a class value // object's body in a minimal API is not nullable and keeps its schema (not-promised openapi-2-nullable-body-keeps-only-its-null; row 257). + // Text-matching site, kept in this step: the wrapped side's document is searched for the spelling. Baseline gap: the twin's + // nullable body is not compared with a plain nullable body ASP.NET writes alone (the three-sided differential). if (openApi2 && !e.Vo.IsValueType && Nullable.GetUnderlyingType(e.Plain) is not null && written.Contains(""" "schema":{"allOf":[{"x-nullable":true}]} """, StringComparison.Ordinal)) - { r.Note(NoteKeys.SchemaOpenApi2NullableBodyKeepsOnlyNull, pp); continue; } + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-body-keeps-only-its-null"), pp); continue; } // KB-027: the component ASP.NET makes for an enum written by name is the schema of the first use it meets, so a wrapped E? // parameter lists null through E's component where E was first met nullable; the value object's parameter is the enum's // content without it wherever the enum is a component (not-promised enum-component-is-its-first-use; row 285 of C21). + // Text-matching site, kept in this step: told by the path's prefix and suffix. Baseline gap: the enum's first use is not + // replayed in a document of its own to show the component it makes (the three-sided differential). if (at.StartsWith("$.parameters", StringComparison.Ordinal) && at.EndsWith(".enum", StringComparison.Ordinal) && wp is JsonArray we && wv is JsonArray ve && we.Count == ve.Count + 1 && we[^1] is null && JsonNode.DeepEquals(new JsonArray(we.Take(ve.Count).Select(x => x?.DeepClone()).ToArray()), ve) && At(JsonNode.Parse(written), at[..^".enum".Length]) is JsonObject held && held.ContainsKey(Hx.ResolvedReference)) - { r.Note(NoteKeys.SchemaEnumComponentIsItsFirstUse, vp); continue; } + { c.Inapplicable(Cite.NotPromised("enum-component-is-its-first-use"), vp); continue; } return $"schema differs for {pp} vs {vp} at {FirstDifference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$")}:\n wrapped {Cut(sp)}\n value object {Cut(sv)}"; } } diff --git a/tools/probe/corpus/harness/Positions.cs b/tools/probe/corpus/harness/Positions.cs index d7266356..989e0d85 100644 --- a/tools/probe/corpus/harness/Positions.cs +++ b/tools/probe/corpus/harness/Positions.cs @@ -234,6 +234,8 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) } if (ep is null && ev is not null) { + // Text-matching site, kept in this step (WS2 step 4, the http family): the depth refusal is told by the message's words. Baseline + // gap: no twin in the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). if (r.Is("json.MaxDepth", "3") && ev is JsonException && ev.Message.Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "charges custom converters"); return null; } // inherent to STJ: any custom converter costs a level (design note D1) if (preserveOwn && e.Wrapped == "own-record-class" && ev is NotSupportedException) { r.Note(NoteKeys.PreserveDocumented); return null; } // documented (Generators README#json; not-promised preserved-references-over-a-walked-class) return $"wrapped serialized ({jp}) but the value object threw {ev.GetType().Name}: {ev.Message}"; diff --git a/tools/probe/corpus/harness/SchemaClaim.cs b/tools/probe/corpus/harness/SchemaClaim.cs index 34c3570e..927750a0 100644 --- a/tools/probe/corpus/harness/SchemaClaim.cs +++ b/tools/probe/corpus/harness/SchemaClaim.cs @@ -14,6 +14,7 @@ // also for a type held only inside a patch; a minimal-API host builds it. The docs name converters and descriptions: a different // naming policy or number handling is not "described differently", so the document builds there (DescribedOptionsAgreement // compares JsonSchemaDescription.For, which neither setting changes). +// Each claim's obligations are checks of their own (Row.Check), every check of a row on the same host or hosts, the answers fetched once. #pragma warning disable xUnit1051 using System.Globalization; using System.Net; @@ -231,8 +232,6 @@ public static class SchemaChecks { static string Cut(string? s) => s is null ? "(null)" : s.Length > 300 ? s[..300] + "..." : s; static void Process(Row r, Entry en) { if (r.Is("process.converter-lookup", "wrapped-type-converter-looked-up-first")) _ = System.ComponentModel.TypeDescriptor.GetConverter(Make.Held(en.Plain)); } - /// The application's own string converter (json.Converters=own-for-wrapped over string) wraps every string it writes: take it off again. - static string? Un(Row r, string? s) => r.Is("json.Converters", "own-for-wrapped") && s is not null && s.StartsWith("own:") ? s[4..].Trim('"') : s; /// The host's minimal-API JSON options, which the document is built from. static JsonSerializerOptions HttpOptions(WebApplication app) => app.Services.GetRequiredService>().Value.SerializerOptions; @@ -302,28 +301,88 @@ static bool Admits(JsonNode? n) return t.Contains("\"null\"") || t.Contains("\"nullable\":true") || t.Contains("\"x-nullable\":true"); } - /// Fetches the document; a failure ASP.NET alone shares is noted and leaves nothing to check (doc is null), any other is returned. - static async Task<(JsonNode? Doc, string? Fail)> DocOrBaseline(Row r, Entry en, HostCfg cfg, WebApplication app) - { - try { return (await Hx.Doc(app), null); } - catch (Exception ex) { return (null, await Hx.DocRefusedByAspNetAlone(r, en, cfg, ex)); } - } - // ============================================================ C08 + /// + /// C08, a check per obligation over two hosts (HFault.Clash beside KFault.Clash, and NsA.Twin.Same beside NsB.Twin.Same): each host's + /// warning, or none, is checked for the obligation its naming and leave-out select (bare-names-warn, qualified-chain-warns, + /// qualified-names-fix, leave-one-out-fixes), with what its endpoints answer; warning-names-both, where a host warns, for the names + /// the warning gives. + /// public static async Task C08(Row r) { var en = Registry.Of(r); Process(r, en); var qualified = r["naming"] == "qualified"; var arity = int.Parse(r["arity"]); var leave = r["leave-out"] == "WithoutFaults-one"; - return await Alike(r, en, twin: false, arity, leave, qualified) ?? await Alike(r, en, twin: true, arity, leave, qualified); + var runs = new[] { await Alike(r, en, twin: false, arity, leave, qualified), await Alike(r, en, twin: true, arity, leave, qualified) }; + string? failed = null; + foreach (var o in runs.SelectMany(a => a.Obligations).Distinct().ToList()) + { + var f = r.Check(o, Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#faults-of-several-types"), c => + { + foreach (var a in runs) if (a.Judge(o) is { } fail) return fail; + c.Note(NoteKeys.Compared, string.Join(", ", runs.Where(a => a.Obligations.Contains(o)).Select(a => a.Action))); + return null; + }); + failed ??= f; + } + return failed; + } + + /// + /// One host of C08: what its endpoints answered and what it logged at startup, judged per obligation by . Warns: + /// the obligation of whether the host warns (and what it answers); the names the warning gives are warning-names-both's. + /// + sealed record AlikeRun(string Action, string? Down, bool Twin, bool Leave, bool Qualified, bool OwnWrapped, List Sides, + List<(string Path, int E, Hx.Wire Wire)> Answers, List Lines) + { + string First => Twin ? "Twin" : "HFault"; + string Second => Twin ? "Twin" : "KFault"; + string Member => Twin ? "Same" : "Clash"; + string Written(string type) => Qualified ? $"{type}.{Member}" : Member; + bool Warn => Written(First) == Written(Second) && !Leave; + public string Warns => Leave ? "leave-one-out-fixes" : !Qualified ? "bare-names-warn" : Twin ? "qualified-chain-warns" : "qualified-names-fix"; + public string[] Obligations => Warn ? [Warns, "warning-names-both"] : [Warns]; + + public string? Judge(string o) + { + if (!Obligations.Contains(o)) return null; + if (Down is not null) return Down; + if (o == Warns) + { + foreach (var (path, e, w) in Answers) + { + if (e == 2 && Leave) + { + // the member left out is not answered (README#leaving-out-what-a-route-cannot-answer): proof that the leave-out applied + if (w.Thrown is null || !w.Thrown.StartsWith(nameof(InvalidOperationException))) return $"{Action} {path} e=2: the member left out was answered: {(int)w.Status} {Cut(w.Body)} {w.Thrown}"; + continue; + } + if (w.Thrown is not null) return $"{Action} {path} e={e}: request threw {w.Thrown}"; + var want = e == 3 ? (Qualified ? "SFault.First" : "First") : Written(e == 1 ? First : Second); + const int status = 409; // Conflict for both clashing members, and SFault.First as the host maps it + if ((int)w.Status != status) return $"{Action} {path} e={e}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; + JsonNode? body; try { body = JsonNode.Parse(w.Body); } catch { return $"{Action} {path} e={e}: body is not JSON: {Cut(w.Body)}"; } + // the application's own string converter (json.Converters=own-for-wrapped over string) wraps every string it writes + var fault = body?["fault"]?.GetValue() is { } f && OwnWrapped && f.StartsWith("own:") ? f[4..].Trim('"') : body?["fault"]?.GetValue(); + if (fault != want) return $"{Action} {path} e={e}: fault {fault}, expected {want}"; + } + if (!Warn) + return Lines.Count == 0 ? null : $"{Action} ({(Qualified ? "qualified" : "bare")} names, {(Leave ? "one left out" : "none left out")}): the errors are written {Written(First)} and {Written(Second)}, yet the host warned: {Cut(Lines[0])}"; + return Lines.Count == Sides.Count ? null : $"{Action}: {Sides.Count} endpoint(s) answer {First}.{Member} and {Second}.{Member} alike as \"{Written(First)}\", {Lines.Count} warning(s) at startup{(Lines.Count > 0 ? ": " + Cut(Lines[0]) : "")}"; + } + var names = Twin ? (Qualified ? new[] { "NsA.Twin.Same", "NsB.Twin.Same" } : ["Twin.Same"]) : ["HFault.Clash", "KFault.Clash"]; + foreach (var l in Lines) + if (names.FirstOrDefault(n => !l.Contains(n, StringComparison.Ordinal)) is { } missing) return $"{Action}: the warning does not name {missing}: {Cut(l)}"; + return null; + } } /// /// One endpoint per side answering two fault types whose clashing members are HFault.Clash and KFault.Clash (bare names meet, /// qualified ones do not) or NsA.Twin.Same and NsB.Twin.Same (one nesting chain in two namespaces: both meet). /// - static async Task Alike(Row r, Entry en, bool twin, int arity, bool leave, bool qualified) + static async Task Alike(Row r, Entry en, bool twin, int arity, bool leave, bool qualified) { var logs = new CategoryLogs(); var action = (twin ? "T" : "K") + arity + (leave ? "Wo" : "None"); @@ -350,51 +409,28 @@ static bool Admits(JsonNode? n) StrictSourceGen(r, s); }, }; + var sides = Sides(r, "alike"); + var ownWrapped = r.Is("json.Converters", "own-for-wrapped"); + var answers = new List<(string, int, Hx.Wire)>(); WebApplication app; try { app = await Hx.Start(r, en, cfg); } - catch (Exception ex) { return $"{action}: host did not start: {ex.GetType().Name}: {Cut(ex.Message)}"; } + catch (Exception ex) { return new(action, $"{action}: host did not start: {ex.GetType().Name}: {Cut(ex.Message)}", twin, leave, qualified, ownWrapped, sides, answers, []); } await using var _ = app; - // what each error is written as on the wire: the client's view of the two - var (first, second) = twin ? ("Twin", "Twin") : ("HFault", "KFault"); - var member = twin ? "Same" : "Clash"; - string Written(string type) => qualified ? $"{type}.{member}" : member; - var alike = Written(first) == Written(second); - var warn = alike && !leave; - var sides = Sides(r, "alike"); foreach (var path in sides) - { foreach (var e in arity == 3 ? new[] { 1, 2, 3 } : [1, 2]) - { - var w = await Hx.Get(app, $"{path}?e={e}"); - if (e == 2 && leave) - { - // the member left out is not answered (README#leaving-out-what-a-route-cannot-answer): proof that the leave-out applied - if (w.Thrown is null || !w.Thrown.StartsWith(nameof(InvalidOperationException))) return $"{action} {path} e=2: the member left out was answered: {(int)w.Status} {Cut(w.Body)} {w.Thrown}"; - continue; - } - if (w.Thrown is not null) return $"{action} {path} e={e}: request threw {w.Thrown}"; - var want = e == 3 ? (qualified ? "SFault.First" : "First") : Written(e == 1 ? first : second); - const int status = 409; // Conflict for both clashing members, and SFault.First as the host maps it - if ((int)w.Status != status) return $"{action} {path} e={e}: status {(int)w.Status}, expected {status}: {Cut(w.Body)}"; - JsonNode? body; try { body = JsonNode.Parse(w.Body); } catch { return $"{action} {path} e={e}: body is not JSON: {Cut(w.Body)}"; } - var fault = Un(r, body?["fault"]?.GetValue()); - if (fault != want) return $"{action} {path} e={e}: fault {fault}, expected {want}"; - } - } - + answers.Add((path, e, await Hx.Get(app, $"{path}?e={e}"))); List lines; lock (logs.Lines) lines = logs.Lines.Where(l => l.Category == "CodoMetis.TypeKit.AspNetCore.FaultResponses" && l.Level == LogLevel.Warning).Select(l => l.Message).ToList(); - if (!warn) - return lines.Count == 0 ? null : $"{action} ({(qualified ? "qualified" : "bare")} names, {(leave ? "one left out" : "none left out")}): the errors are written {Written(first)} and {Written(second)}, yet the host warned: {Cut(lines[0])}"; - if (lines.Count != sides.Count) return $"{action}: {sides.Count} endpoint(s) answer {first}.{member} and {second}.{member} alike as \"{Written(first)}\", {lines.Count} warning(s) at startup{(lines.Count > 0 ? ": " + Cut(lines[0]) : "")}"; - var names = twin ? (qualified ? new[] { "NsA.Twin.Same", "NsB.Twin.Same" } : ["Twin.Same"]) : ["HFault.Clash", "KFault.Clash"]; - foreach (var l in lines) - if (names.FirstOrDefault(n => !l.Contains(n, StringComparison.Ordinal)) is { } missing) return $"{action}: the warning does not name {missing}: {Cut(l)}"; - return null; + return new(action, null, twin, leave, qualified, ownWrapped, sides, answers, lines); } // ============================================================ C12 + /// + /// C12, a check per obligation on one host: the source's own (own-converter-describes-itself, or options-describe for + /// JsonSchemaDescriptions) at a property, every-position at every position the docs name, and for the decimal converter + /// parameter-keeps-aspnet, the query parameter against what ASP.NET writes without converters. + /// public static async Task C12(Row r) { var en = Registry.Of(r); Process(r, en); @@ -419,57 +455,79 @@ void Register(JsonSerializerOptions o) var cfg = Cfg(r, [dec ? "Dec" : "Coord"], b => { if (dec) b.MapPost("/s/dec/m", (DecDto body, decimal q) => body); else b.MapPost("/s/coord/m", (CoordDto body) => body); }, s => { s.PostConfigure(o => Register(o.SerializerOptions)); s.PostConfigure(o => Register(o.JsonSerializerOptions)); }); - WebApplication app; + WebApplication? app = null; string? down = null; try { app = await Hx.Start(r, en, cfg); } - catch (Exception ex) { return "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } + catch (Exception ex) { down = "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } await using var _ = app; - var (doc, fail) = await DocOrBaseline(r, en, cfg, app); - if (fail is not null) return fail; - if (doc is null) return null; - var http = HttpOptions(app); - var want = Norm(dec ? new DecimalStringJsonConverter().DescribeJson(typeof(decimal), http)!.Schema - : source == "IDescribedJsonConverter" ? CoordConverter.Schema() : CoordPlainConverter.Schema()); - foreach (var path in Sides(r, family)) + JsonNode? doc = null; Exception? docFailed = null; + if (app is not null) { try { doc = await Hx.Doc(app); } catch (Exception ex) { docFailed = ex; } } + var described = source == "JsonSchemaDescriptions" ? "options-describe" : "own-converter-describes-itself"; + string[] obligations = dec ? [described, "every-position", "parameter-keeps-aspnet"] : [described, "every-position"]; + string? failed = null; + foreach (var o in obligations) { - var op = Op(doc, path, "post"); - if (op is null) return $"document has no POST {path}"; - if (BodySchema(doc, op) is not JsonObject body) return $"{path}: no request body schema"; - var props = body["properties"]?.AsObject(); - JsonNode? P(string clr) => props?[JsonName(http, dto, clr)]; - var checks = new (string What, JsonNode? Got, bool Null)[] - { - ("a property", P("At"), false), ("a nullable property", P("Maybe"), true), ("a list's items", P("Route")?["items"], false), - ("a dictionary's additionalProperties", P("ByName")?["additionalProperties"], false), ("inside a value object", P("Vo"), false), - ("inside a patch", P("Patched"), true), - }; - foreach (var (what, got, admitsNull) in checks) + var against = o == "parameter-keeps-aspnet" ? Baseline.Framework : Baseline.Docs("docs/json.md#describing-what-a-converter-writes"); + var f = await r.Check(o, against, async c => { - if (got is null) return $"{path}: {what} is missing from the body schema: {Cut(body.ToJsonString())}"; - // ASP.NET's OpenAPI 2.0 writer drops the reference of a nullable struct component ({"allOf":[{"x-nullable":true}]}): - // the plain struct beside it, which has no converter, is written the same, so the position has nothing to check - if (what == "a nullable property" && !dec && P("Twin") is { } twin && JsonNode.DeepEquals(got, twin) && Norm(twin) is JsonObject { Count: 0 }) + if (down is not null) return down; + // a document ASP.NET alone refuses the same way has nothing to check (noted there) + if (docFailed is not null) return await Hx.DocRefusedByAspNetAlone(r, en, cfg, docFailed); + var http = HttpOptions(app!); + var want = Norm(dec ? new DecimalStringJsonConverter().DescribeJson(typeof(decimal), http)!.Schema + : source == "IDescribedJsonConverter" ? CoordConverter.Schema() : CoordPlainConverter.Schema()); + foreach (var path in Sides(r, family)) { - r.Note(NoteKeys.SchemaNullableReferenceLostForPlainToo, path); - continue; + var op = Op(doc!, path, "post"); + if (op is null) return $"document has no POST {path}"; + if (o == "parameter-keeps-aspnet") + { + // "Parameters aren't JSON": the query parameter keeps ASP.NET's number, never the converter's string + var q = op["parameters"]?.AsArray().FirstOrDefault(p => p?["name"]?.GetValue() == "q"); + if (q is null) return $"{path}: no parameter q"; + var qs = Hx.Deep(doc!, q["schema"] ?? q); + if (!(qs?.ToJsonString() ?? "").Contains("\"number\"")) return $"{path}: the query parameter q is described as {Cut(qs?.ToJsonString())}, not ASP.NET's number"; + if (JsonNode.DeepEquals(Norm(qs), want)) return $"{path}: the query parameter q is described as the converter's JSON"; + continue; + } + if (BodySchema(doc!, op) is not JsonObject body) return $"{path}: no request body schema"; + var props = body["properties"]?.AsObject(); + JsonNode? P(string clr) => props?[JsonName(http, dto, clr)]; + var positions = new (string What, JsonNode? Got, bool Null)[] + { + ("a property", P("At"), false), ("a nullable property", P("Maybe"), true), ("a list's items", P("Route")?["items"], false), + ("a dictionary's additionalProperties", P("ByName")?["additionalProperties"], false), ("inside a value object", P("Vo"), false), + ("inside a patch", P("Patched"), true), + }; + foreach (var (what, got, admitsNull) in positions) + { + // the source's own obligation is held at a property; every-position at each position the docs name + if (o != "every-position" && what != "a property") continue; + if (got is null) return $"{path}: {what} is missing from the body schema: {Cut(body.ToJsonString())}"; + // ASP.NET's OpenAPI 2.0 writer drops the reference of a nullable struct component ({"allOf":[{"x-nullable":true}]}): + // the plain struct beside it, which has no converter, is written the same, so the position has nothing to check + if (what == "a nullable property" && !dec && P("Twin") is { } twin && JsonNode.DeepEquals(got, twin) && Norm(twin) is JsonObject { Count: 0 }) + { + c.Note(NoteKeys.SchemaNullableReferenceLostForPlainToo, path); + continue; + } + if (!JsonNode.DeepEquals(Norm(got), want)) return $"{path}: {what} is {Cut(got.ToJsonString())}, the {source} says {want!.ToJsonString()}"; + if (admitsNull && !Admits(got)) return $"{path}: {what} does not admit null: {Cut(got.ToJsonString())}"; + } } - if (!JsonNode.DeepEquals(Norm(got), want)) return $"{path}: {what} is {Cut(got.ToJsonString())}, the {source} says {want!.ToJsonString()}"; - if (admitsNull && !Admits(got)) return $"{path}: {what} does not admit null: {Cut(got.ToJsonString())}"; - } - if (dec) - { - // "Parameters aren't JSON": the query parameter keeps ASP.NET's number, never the converter's string - var q = op["parameters"]?.AsArray().FirstOrDefault(p => p?["name"]?.GetValue() == "q"); - if (q is null) return $"{path}: no parameter q"; - var qs = Hx.Deep(doc, q["schema"] ?? q); - if (!(qs?.ToJsonString() ?? "").Contains("\"number\"")) return $"{path}: the query parameter q is described as {Cut(qs?.ToJsonString())}, not ASP.NET's number"; - if (JsonNode.DeepEquals(Norm(qs), want)) return $"{path}: the query parameter q is described as the converter's JSON"; - } + c.Note(NoteKeys.Compared, source); + return null; + }); + failed ??= f; } - r.Note(NoteKeys.Compared, source); - return null; + return failed; } // ============================================================ C13 + /// + /// C13, a check per obligation on one host: where the options require the constructor-parameter patch, doc-required-where-rejected + /// (a body without it is rejected, and the document lists it in required); elsewhere doc-optional-where-absent (the document leaves it + /// out of required); and absent-is-default for what the patch reads where a body leaves it out, sets it null or gives it a value. + /// public static async Task C13(Row r) { var en = Registry.Of(r); Process(r, en); @@ -493,62 +551,156 @@ void Register(JsonSerializerOptions o) var respect = http.RespectRequiredConstructorParameters; // "a patch that is a constructor parameter is required" under the option; an init property "keeps its default, which is Absent" var required = ctor && respect; + var listing = required ? "doc-required-where-rejected" : "doc-optional-where-absent"; var floor = JsonName(http, dto, "Floor"); // 3 as the options read an int: an application's own converter for int (json.Converters=own-for-wrapped) reads its own spelling var three = Reads(http, "3") ? "3" : JsonSerializer.Serialize(3, http); - var (doc, fail) = await DocOrBaseline(r, en, cfg, app); - if (fail is not null) return fail; + JsonNode? doc = null; Exception? docFailed = null; + try { doc = await Hx.Doc(app); } + catch (Exception ex) { docFailed = ex; } + // What each side answered to a body that leaves the patch out, sets it null, and gives it a value; where the request threw, MVC's + // validator throws for any nested struct of a body under a small MaxValidationDepth, which the plain twin on the same side shows. + var bodies = new[] { ("absent", "{}"), ("cleared", $"{{\"{floor}\":null}}"), ("set", $"{{\"{floor}\":{three}}}") }; + var answers = new List<(string Path, string Kind, string Body, Hx.Wire Wire, string? TwinThrew)>(); foreach (var path in Sides(r, family)) - { - // MVC's validator throws for any nested struct of a body under a small MaxValidationDepth: the plain twin on the same side shows it - async Task<(Hx.Wire? Wire, string? Fail)> Post(string body) + foreach (var (kind, body) in bodies) { var w = await Hx.Send(app, HttpMethod.Post, path, body); - if (w.Thrown is null) return (w, null); - var twin = path.EndsWith("/c", StringComparison.Ordinal) ? await Hx.Send(app, HttpMethod.Post, path.Replace($"/s/{family}/c", "/s/box/c"), "{}") : null; - if (twin?.Thrown is { } t && t.Split(':')[0] == w.Thrown.Split(':')[0]) { r.Note(NoteKeys.MvcPlainNestedBodyThrowsToo, t.Split(':')[0]); return (null, null); } - return (null, $"{path} {body}: request threw {w.Thrown}"); + var twin = w.Thrown is not null && path.EndsWith("/c", StringComparison.Ordinal) ? await Hx.Send(app, HttpMethod.Post, path.Replace($"/s/{family}/c", "/s/box/c"), "{}") : null; + answers.Add((path, kind, body, w, twin?.Thrown is { } t && t.Split(':')[0] == w.Thrown!.Split(':')[0] ? t.Split(':')[0] : null)); } - var (absent, f1) = await Post("{}"); - if (f1 is not null) return f1; - if (absent is not null) - { - if (required && absent.Status != HttpStatusCode.BadRequest) return $"{path}: a body without the constructor-parameter patch was answered {(int)absent.Status} {Cut(absent.Body)} under RespectRequiredConstructorParameters, not rejected"; - if (!required && (absent.Status != HttpStatusCode.OK || absent.Body != "Absent")) return $"{path}: a body without the {(ctor ? "constructor-parameter" : "init")} patch was answered {(int)absent.Status} {Cut(absent.Body)}, not Absent"; - } - var (cleared, f2) = await Post($"{{\"{floor}\":null}}"); - if (f2 is not null) return f2; - if (cleared is not null && (cleared.Status != HttpStatusCode.OK || cleared.Body != "Null")) return $"{path}: a body with {floor}: null was answered {(int)cleared.Status} {Cut(cleared.Body)}, not Null"; - var (set, f3) = await Post($"{{\"{floor}\":{three}}}"); - if (f3 is not null) return f3; - if (set is not null && (set.Status != HttpStatusCode.OK || set.Body != "Value")) return $"{path}: a body with {floor}: {three} was answered {(int)set.Status} {Cut(set.Body)}, not Value"; - } - if (doc is null) return null; // ASP.NET alone refuses this document too (noted) - foreach (var path in Sides(r, family)) + string? failed = null; + foreach (var o in new[] { listing, "absent-is-default" }) { - var op = Op(doc, path, "post"); - if (op is null) return $"document has no POST {path}"; - if (BodySchema(doc, op) is not JsonObject body) return $"{path}: no request body schema"; - if (body["properties"]?[floor] is null) return $"{path}: the body schema has no property {floor}: {Cut(body.ToJsonString())}"; - var listed = body["required"]?.AsArray().Any(x => x?.GetValue() == floor) == true; - if (listed != required) - return $"{path}: the {(ctor ? "constructor-parameter" : "init")} patch {floor} is {(listed ? "" : "not ")}in required under RespectRequiredConstructorParameters={respect}, the serializer {(required ? "rejects" : "accepts")} a body without it"; + var f = await r.Check(o, Baseline.Docs("docs/merge-patches.md#required-constructor-parameters"), async c => + { + foreach (var (path, kind, body, w, twinThrew) in answers) + { + // a body without the patch is the listing's premise where the options reject it, else what absent-is-default reads + if ((kind == "absent" && required ? listing : "absent-is-default") != o) continue; + if (twinThrew is not null) + { + // No sentence cites it: MVC's validator throws for the plain twin's nested struct too, so the check cannot tell. + c.Inconclusive($"{path} {body}: MVC's validator throws {twinThrew} for the plain twin's body too"); + continue; + } + if (w.Thrown is not null) return $"{path} {body}: request threw {w.Thrown}"; + switch (kind) + { + case "absent" when required && w.Status != HttpStatusCode.BadRequest: + return $"{path}: a body without the constructor-parameter patch was answered {(int)w.Status} {Cut(w.Body)} under RespectRequiredConstructorParameters, not rejected"; + case "absent" when !required && (w.Status != HttpStatusCode.OK || w.Body != "Absent"): + return $"{path}: a body without the {(ctor ? "constructor-parameter" : "init")} patch was answered {(int)w.Status} {Cut(w.Body)}, not Absent"; + case "cleared" when w.Status != HttpStatusCode.OK || w.Body != "Null": + return $"{path}: a body with {floor}: null was answered {(int)w.Status} {Cut(w.Body)}, not Null"; + case "set" when w.Status != HttpStatusCode.OK || w.Body != "Value": + return $"{path}: a body with {floor}: {three} was answered {(int)w.Status} {Cut(w.Body)}, not Value"; + } + } + if (o != listing) return null; + // a document ASP.NET alone refuses the same way has nothing to check (noted there) + if (docFailed is not null) return await Hx.DocRefusedByAspNetAlone(r, en, cfg, docFailed); + foreach (var path in Sides(r, family)) + { + var op = Op(doc!, path, "post"); + if (op is null) return $"document has no POST {path}"; + if (BodySchema(doc!, op) is not JsonObject body) return $"{path}: no request body schema"; + if (body["properties"]?[floor] is null) return $"{path}: the body schema has no property {floor}: {Cut(body.ToJsonString())}"; + var listed = body["required"]?.AsArray().Any(x => x?.GetValue() == floor) == true; + if (listed != required) + return $"{path}: the {(ctor ? "constructor-parameter" : "init")} patch {floor} is {(listed ? "" : "not ")}in required under RespectRequiredConstructorParameters={respect}, the serializer {(required ? "rejects" : "accepts")} a body without it"; + } + c.Note(NoteKeys.Compared, $"{floor} {(required ? "" : "not ")}required"); + return null; + }); + failed ??= f; } - return null; + return failed; } static bool Reads(JsonSerializerOptions o, string json) { try { return JsonSerializer.Deserialize(json, o) == 3; } catch (Exception ex) when (ex is JsonException or InvalidOperationException) { return false; } } // ============================================================ C16 + /// + /// C16, a check per obligation over two hosts (a described type as a property, and one held only inside a patch). Where the host has + /// controllers and only the minimal-API options carry the converter: differing-refused and patch-held-refused (the document refused + /// with an InvalidOperationException naming the type, for the property's host and the patch's), and error-names-type-and-descriptions + /// (the refusal names what the options say of it, for both), against the refusal. Elsewhere the document builds and describes the + /// type as the converter says: minimal-host-not-compared for a host of minimal APIs alone, same-converters-build where both option + /// sets carry the converter and differ only in naming policy or number handling. + /// public static async Task C16(Row r) { var en = Registry.Of(r); Process(r, en); - return await Agree(r, en, patch: false) ?? await Agree(r, en, patch: true); + var refuse = r["difference"] == "converter-only-http" && Hx.HasMvc(r); + var runs = new[] { await Agree(r, en, patch: false), await Agree(r, en, patch: true) }; + var built = r["difference"] == "converter-only-http" ? "minimal-host-not-compared" : "same-converters-build"; + (string Obligation, AgreeRun[] Runs)[] checks = refuse + ? [("differing-refused", [runs[0]]), ("patch-held-refused", [runs[1]]), ("error-names-type-and-descriptions", runs)] + : [(built, runs)]; + string? failed = null; + foreach (var (o, mine) in checks) + { + var f = await r.Check(o, refuse ? Baseline.Refusal : Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), async c => + { + foreach (var a in mine) + if ((refuse ? await a.Refusal(c, r, en, names: o == "error-names-type-and-descriptions") : await a.Built(c, r, en)) is { } fail) return fail; + return null; + }); + failed ??= f; + } + return failed; } /// A naming policy no row sets: the minimal-API options' names then differ from MVC's whatever the row says. sealed class PrefixPolicy : JsonNamingPolicy { public override string ConvertName(string name) => "h_" + name; } - static async Task Agree(Row r, Entry en, bool patch) + /// One host of C16: its document or the exception building it threw, judged by or . + sealed record AgreeRun(bool Patch, Type Type, HostCfg Cfg, List Sides, string? Down, JsonNode? Doc, Exception? Failed, string? Name) + { + string Said => (Patch ? CoordPConverter.Schema() : CoordConverter.Schema()).ToJsonString(); + JsonNode? Want => Norm(Patch ? CoordPConverter.Schema() : CoordConverter.Schema()); + + /// + /// The document refused with an InvalidOperationException naming the type, and with what the minimal-API + /// options say of it; a refusal ASP.NET alone makes the same way has nothing to check. + /// + public async Task Refusal(Check c, Row r, Entry en, bool names) + { + if (Down is not null) return Down; + if (Failed is null) return $"{(Patch ? "a type held only inside a patch, " : "")}{Type.Name}: the host has controllers, its minimal-API options alone describe it ({Want!.ToJsonString()}), and the document was built"; + var named = Failed is InvalidOperationException && Failed.Message.Contains($"'{Type}'", StringComparison.Ordinal); + if (named && (!names || Failed.Message.Contains(Said, StringComparison.Ordinal))) + { + c.Note(NoteKeys.Compared, names ? "refused naming the type and the minimal-API options' description" : "refused naming the type"); + return null; + } + // not the refusal the claim names: judged against ASP.NET alone + return await Hx.DocRefusedByAspNetAlone(r, en, Cfg, Failed) is null ? null + : named ? $"{(Patch ? "patch-held " : "")}{Type.Name}: the refusal names the type but not what the options say ({Said}): {Cut(Failed.Message)}" + : $"{(Patch ? "patch-held " : "")}{Type.Name}: the document was refused otherwise than naming the type and what the options say: {Failed.GetType().Name}: {Cut(Failed.Message)}"; + } + + /// The document built, describing the type as the converter on both option sets, or the minimal-API options alone, says. + public async Task Built(Check c, Row r, Entry en) + { + if (Down is not null) return Down; + // a document ASP.NET alone refuses the same way has nothing to check (noted there) + if (Failed is not null) return await Hx.DocRefusedByAspNetAlone(r, en, Cfg, Failed); + foreach (var path in Sides) + { + var op = Op(Doc!, path, "post"); + if (op is null) return $"document has no POST {path}"; + if (BodySchema(Doc!, op) is not JsonObject body) return $"{path}: no request body schema"; + var got = body["properties"]?[Name!]; + if (got is null) return $"{path}: the body schema has no property {Name} (the minimal-API options' name): {Cut(body.ToJsonString())}"; + if (!JsonNode.DeepEquals(Norm(got), Want)) return $"{path}: {Name} is {Cut(got.ToJsonString())}, the converter on both option sets says {Want!.ToJsonString()}"; + } + c.Note(NoteKeys.Compared, $"{Type.Name} described as its converter says"); + return null; + } + } + + static async Task Agree(Row r, Entry en, bool patch) { var difference = r["difference"]; var type = patch ? typeof(CoordP) : typeof(Coord); @@ -572,41 +724,14 @@ sealed class PrefixPolicy : JsonNamingPolicy { public override string ConvertNam }); if (difference != "converter-only-http") s.PostConfigure(o => o.JsonSerializerOptions.Converters.Insert(0, Converter())); }); + var sides = Sides(r, patch ? "agreep" : "agree"); WebApplication app; try { app = await Hx.Start(r, en, cfg); } - catch (Exception ex) { return "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } + catch (Exception ex) { return new(patch, type, cfg, sides, "host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message), null, null, null); } await using var _ = app; - var refuse = difference == "converter-only-http" && Hx.HasMvc(r); - var said = (patch ? CoordPConverter.Schema() : CoordConverter.Schema()).ToJsonString(); - var want = Norm(patch ? CoordPConverter.Schema() : CoordConverter.Schema()); - JsonNode? doc; - try { doc = await Hx.Doc(app); } - catch (Exception ex) - { - if (refuse && ex is InvalidOperationException && ex.Message.Contains($"'{type}'", StringComparison.Ordinal) && ex.Message.Contains(said, StringComparison.Ordinal)) - { - r.Note(NoteKeys.Compared, "refused naming the type and the minimal-API options' description"); - return null; - } - // not the refusal the claim names: judged against ASP.NET alone - return await Hx.DocRefusedByAspNetAlone(r, en, cfg, ex) is { } alone - ? (refuse ? $"{(patch ? "patch-held " : "")}{type.Name}: the document was refused otherwise than naming the type and what the options say: {ex.GetType().Name}: {Cut(ex.Message)}" : alone) - : null; - } - if (refuse) return $"{(patch ? "a type held only inside a patch, " : "")}{type.Name}: the host has controllers, its minimal-API options alone describe it ({want!.ToJsonString()}), and the document was built"; - if (doc is null) return null; - var http = HttpOptions(app); - var name = JsonName(http, dto, patch ? "P" : "At"); - foreach (var path in Sides(r, patch ? "agreep" : "agree")) - { - var op = Op(doc, path, "post"); - if (op is null) return $"document has no POST {path}"; - if (BodySchema(doc, op) is not JsonObject body) return $"{path}: no request body schema"; - var got = body["properties"]?[name]; - if (got is null) return $"{path}: the body schema has no property {name} (the minimal-API options' name): {Cut(body.ToJsonString())}"; - if (!JsonNode.DeepEquals(Norm(got), want)) return $"{path}: {name} is {Cut(got.ToJsonString())}, the converter on both option sets says {want!.ToJsonString()}"; - } - return null; + var name = JsonName(HttpOptions(app), dto, patch ? "P" : "At"); + try { return new(patch, type, cfg, sides, null, await Hx.Doc(app), null, name); } + catch (Exception ex) { return new(patch, type, cfg, sides, null, null, ex, name); } } } } diff --git a/tools/probe/corpus/probes/C04WithoutFaultsProbe.cs b/tools/probe/corpus/probes/C04WithoutFaultsProbe.cs index fc9ad565..d050b5b1 100644 --- a/tools/probe/corpus/probes/C04WithoutFaultsProbe.cs +++ b/tools/probe/corpus/probes/C04WithoutFaultsProbe.cs @@ -7,7 +7,13 @@ // MapControllers()'s builder (README: "A route group or a controller says it for its endpoints"; "Any convention builder that covers several // endpoints works the same"); a controller class beside a sibling controller. combined: a wider scope also gives Down 507 (a group, the // controller class, or MapControllers()'s builder). With scope=group the 507's whole scope leaves Down out, so the host must not start. -// Oracle: FaultClaim (README rules over the declarations); the other members (Missing, Clash) answer as mapped. +// Oracle: FaultClaim (README rules over the declarations); the other members (Missing, Clash) answer as mapped. A check per obligation: +// doc-omits-left-out: every endpoint's operation (statuses, order, problem schema, problem types); +// reporting-left-out-throws: reporting the member left out throws naming the endpoint and the member, against the refusal; and, outside +// the rows of narrows-wider-status, what the other members answer, which the README sets against "a status the document doesn't list"; +// narrows-wider-status (scope endpoint or controller, combined): what every member but the one left out answers, the kept 507 among +// them, and which statuses each operation lists; +// left-out-everywhere-stops-start (scope group, combined): the host's refusal, naming HFault.Down and 507, the row's one check. using Probes.Faults; using Probes.Shapes; namespace Probes; @@ -21,7 +27,21 @@ public sealed class C04WithoutFaultsProbe public static Spec Spec(Probes.Shapes.Row r) { var combined = r["combined"] == "with-WithFaultStatus-on-group"; - var s = new Spec { Errors = [FxErr.E("Missing"), FxErr.E("Clash"), FxErr.E("Down")] }; + var narrows = combined && r["scope"] is "endpoint" or "controller"; + var s = new Spec + { + Errors = [FxErr.E("Missing"), FxErr.E("Clash"), FxErr.E("Down")], + Against = Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#leaving-out-what-a-route-cannot-answer"), + Refusals = ["reporting-left-out-throws", "left-out-everywhere-stops-start"], + Of = (aspect, _) => aspect switch + { + Aspect.Start => ["left-out-everywhere-stops-start"], + Aspect.LeftOut => ["reporting-left-out-throws"], + Aspect.DocListed when narrows => ["doc-omits-left-out", "narrows-wider-status"], + Aspect.DocListed or Aspect.DocOrder or Aspect.DocDescribes or Aspect.DocTypes => ["doc-omits-left-out"], + _ => [narrows ? "narrows-wider-status" : "reporting-left-out-throws"], + }, + }; switch (r["scope"]) { case "endpoint": diff --git a/tools/probe/corpus/probes/C06FaultKindsProbe.cs b/tools/probe/corpus/probes/C06FaultKindsProbe.cs index e6c7abb1..584a9377 100644 --- a/tools/probe/corpus/probes/C06FaultKindsProbe.cs +++ b/tools/probe/corpus/probes/C06FaultKindsProbe.cs @@ -4,7 +4,10 @@ // default, which is no kind, fails this call." The member of the row's kind (HFault.Stale, Garbled, Bad, Down, none with a description) and // HFault.Missing (described "Missing.") are reported on a minimal endpoint and an MVC action; the operation lists its kind's status among // every member's, with no problem type (the mapping by kind gives none). The fault sets are mapped per set (AddFaultResponses(), -// (), ()). Oracle: FaultClaim; and AddFaultResponses() refused, naming its member of no kind. +// (), ()). Oracle: FaultClaim; and AddFaultResponses() refused, naming its member of no kind. A check per obligation: +// kind-gives-status: each member's status, fault and type, and the operation (statuses, order, problem schema, problem types); +// description-as-detail: HFault.Missing's detail is its description; no-description-no-detail: the row's member has none; +// member-must-have-kind: AddFaultResponses() refused, against the refusal, in every row. using CodoMetis.TypeKit.AspNetCore; using Microsoft.Extensions.DependencyInjection; using Probes.Faults; @@ -14,7 +17,14 @@ public sealed class C06FaultKindsProbe { public static TheoryData Data() => Rows.Data("C06FaultKindsProbe"); [Theory, MemberData(nameof(Data))] - public Task Row(string probe, int idx) => Rec.Run("C06", idx, async r => NoKindRefused() ?? await FaultClaim.Run(r, Spec(r)), probe); + public Task Row(string probe, int idx) => Rec.Run("C06", idx, Check, probe); + + static async Task Check(Probes.Shapes.Row r) + { + var noKind = r.Check("member-must-have-kind", Baseline.Refusal, _ => NoKindRefused()); + var answered = await FaultClaim.Run(r, Spec(r)); + return noKind ?? answered; + } /// "A member must have a kind. A member whose kind is default, which is no kind, fails this call." static string? NoKindRefused() @@ -30,6 +40,10 @@ public sealed class C06FaultKindsProbe Minimal = new MGrp("/c6", [], [], [new MEp("k", [])]), Controllers = ["C06K"], Typed = false, + Against = Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#a-closed-set-as-the-fault-type"), + Of = (aspect, m) => aspect == Aspect.Detail + ? [m?.Q == "HFault.Missing" ? "description-as-detail" : "no-description-no-detail"] + : ["kind-gives-status"], Errors = [FxErr.E(r["kind"] switch { "PreconditionFailed" => "Stale", "Malformed" => "Garbled", "Invalid" => "Bad", _ => "Down" }), FxErr.E("Missing")], }; } diff --git a/tools/probe/corpus/probes/C07BlanketMappingProbe.cs b/tools/probe/corpus/probes/C07BlanketMappingProbe.cs index 64ee21ba..2b1fc3fd 100644 --- a/tools/probe/corpus/probes/C07BlanketMappingProbe.cs +++ b/tools/probe/corpus/probes/C07BlanketMappingProbe.cs @@ -9,7 +9,10 @@ // enum, a set with a member of no kind, or a set the mapping refuses. Under by-kind no mapping for every set throws or gives a non-error // status, so there the refusing set's own AddFaultResponses() does, which the README names as the same failure. // Oracle: FaultClaim (wire and document per member, as mapped), and the refusals as the README states them. BaseProbe runs the HTTP -// payload calibration over the same rows. +// payload calibration over the same rows. A check per obligation: by-kind or own-mapping (the row's mapping) for what every member answers +// and every operation lists; own-set-keeps-mapping (set own-mapped-set) for HFault's answers, mapped on their own, and the operations; +// enum-needs-own-mapping, member-without-kind-stops-start and mapping-refusal-stops-start (their sets) for the host's refusal, the row's +// one check, against the refusal. using Probes.Faults; using Probes.Shapes; using CodoMetis.TypeKit.AspNetCore; @@ -29,6 +32,12 @@ public static Spec Spec(Probes.Shapes.Row r) { var own = r["mapping"] == "own-IFault-mapping"; var set = r["set"]; + var mapping = own ? "own-mapping" : "by-kind"; + var refusal = set switch + { + "enum-unmapped" => "enum-needs-own-mapping", "member-without-kind" => "member-without-kind-stops-start", + "mapping-throws" or "status-not-error" => "mapping-refusal-stops-start", _ => null, + }; var s = new Spec { Minimal = new MGrp("/c7", [], [], [new MEp("k", [])]), @@ -38,6 +47,12 @@ public static Spec Spec(Probes.Shapes.Row r) Typed = own, Extra = b => FxSets.Map(b, set), ExtraControllers = FxSets.Controllers(set), + Against = Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#every-fault-set-at-once"), + Refusals = refusal is null ? [] : [refusal], + Of = (aspect, m) => aspect == Aspect.Start ? [refusal ?? mapping] + : set != "own-mapped-set" ? [mapping] + : m is null ? [mapping, "own-set-keeps-mapping"] + : m.Type == "HFault" ? ["own-set-keeps-mapping"] : [mapping], }; s.Services = sv => { diff --git a/tools/probe/corpus/probes/C08SameNameWarningProbe.cs b/tools/probe/corpus/probes/C08SameNameWarningProbe.cs index 74d45f0c..9f008d48 100644 --- a/tools/probe/corpus/probes/C08SameNameWarningProbe.cs +++ b/tools/probe/corpus/probes/C08SameNameWarningProbe.cs @@ -1,4 +1,4 @@ -// Claim C08-same-name-warning (src/CodoMetis.TypeKit.AspNetCore/README.md#faults-of-several-types, Keep names distinct). The check is SchemaChecks.C08 in harness/SchemaClaim.cs. +// Claim C08-same-name-warning (src/CodoMetis.TypeKit.AspNetCore/README.md#faults-of-several-types, Keep names distinct). Its checks, one per obligation, are SchemaChecks.C08 in harness/SchemaClaim.cs. using Probes.Schema; using Probes.Shapes; namespace Probes; diff --git a/tools/probe/corpus/probes/C12DescribedConverterProbe.cs b/tools/probe/corpus/probes/C12DescribedConverterProbe.cs index dc7ed41e..6e25d05f 100644 --- a/tools/probe/corpus/probes/C12DescribedConverterProbe.cs +++ b/tools/probe/corpus/probes/C12DescribedConverterProbe.cs @@ -1,4 +1,4 @@ -// Claim C12-described-converter (docs/json.md#describing-what-a-converter-writes and the AspNetCore README's A described type). The check is SchemaChecks.C12 in harness/SchemaClaim.cs. +// Claim C12-described-converter (docs/json.md#describing-what-a-converter-writes and the AspNetCore README's A described type). Its checks, one per obligation, are SchemaChecks.C12 in harness/SchemaClaim.cs. using Probes.Schema; using Probes.Shapes; namespace Probes; diff --git a/tools/probe/corpus/probes/C13PatchRequiredProbe.cs b/tools/probe/corpus/probes/C13PatchRequiredProbe.cs index f7dcff89..26197cae 100644 --- a/tools/probe/corpus/probes/C13PatchRequiredProbe.cs +++ b/tools/probe/corpus/probes/C13PatchRequiredProbe.cs @@ -1,4 +1,4 @@ -// Claim C13-patch-required (docs/merge-patches.md#required-constructor-parameters). The check is SchemaChecks.C13 in harness/SchemaClaim.cs. +// Claim C13-patch-required (docs/merge-patches.md#required-constructor-parameters). Its checks, one per obligation, are SchemaChecks.C13 in harness/SchemaClaim.cs. using Probes.Schema; using Probes.Shapes; namespace Probes; diff --git a/tools/probe/corpus/probes/C14DeclaredProblemProbe.cs b/tools/probe/corpus/probes/C14DeclaredProblemProbe.cs index 8e04e68a..e8a53d2a 100644 --- a/tools/probe/corpus/probes/C14DeclaredProblemProbe.cs +++ b/tools/probe/corpus/probes/C14DeclaredProblemProbe.cs @@ -4,6 +4,10 @@ // minimal .ProducesProblem(404) / MVC [ProducesResponseType(404, "application/problem+json")] (what ProducesProblem declares), // [ProducesResponseType(404)] on the lambda or the action, or nothing (the control). The mapping gives a problem type per kind. // Oracle: FaultClaim: the 404's schema describes fault and rule, its description names https://p.example/NotFound, the statuses ascending. +// A check per obligation: declared-describes-fault (the problem schemas' fault and rule), declared-names-types (the problem types the +// descriptions name, and what each member answers), ascending-with-declared (which statuses each operation lists, and their order), and in +// the rows of [ProducesResponseType(404)] application-json-declared (its schema and its problem types). declared=none is the +// control: the same checks, where the endpoint declares nothing. Not checked here: that application/json keeps its media type. using Probes.Faults; using Probes.Shapes; namespace Probes; @@ -21,6 +25,18 @@ public static Spec Spec(Probes.Shapes.Row r) "ProducesResponseType" => (new MEp("d", [], [404], "prt"), "C14Prt"), _ => (new MEp("d", []), "C14None"), }; - return new Spec { Minimal = new MGrp("/c14", [], [], [ep]), Controllers = [ctl], Errors = [FxErr.E("Missing"), FxErr.E("Clash")] }; + var json = r["declared"] == "ProducesResponseType"; + return new Spec + { + Minimal = new MGrp("/c14", [], [], [ep]), Controllers = [ctl], Errors = [FxErr.E("Missing"), FxErr.E("Clash")], + Against = Baseline.Docs("CHANGELOG.md#2.1.0-fixed"), + Of = (aspect, _) => aspect switch + { + Aspect.DocDescribes => json ? ["declared-describes-fault", "application-json-declared"] : ["declared-describes-fault"], + Aspect.DocTypes => json ? ["declared-names-types", "application-json-declared"] : ["declared-names-types"], + Aspect.DocListed or Aspect.DocOrder => ["ascending-with-declared"], + _ => ["declared-names-types"], + }, + }; } } diff --git a/tools/probe/corpus/probes/C15MvcFaultStatusDocProbe.cs b/tools/probe/corpus/probes/C15MvcFaultStatusDocProbe.cs index c2c3ee2e..d4e3b0aa 100644 --- a/tools/probe/corpus/probes/C15MvcFaultStatusDocProbe.cs +++ b/tools/probe/corpus/probes/C15MvcFaultStatusDocProbe.cs @@ -5,7 +5,9 @@ // set there wins over an action's own"; "an action's over its controller's": an action with its own Denied 452 sits beside each scope. // Minimal hosts take the equivalent of each setter (README: "Any convention builder that covers several endpoints works the same"): // MapControllers()'s builder = a group over every endpoint, an action = the endpoint, a controller = a group beside a sibling endpoint; -// there "An endpoint's own wins over its group's". Oracle: FaultClaim. +// there "An endpoint's own wins over its group's". Oracle: FaultClaim. A check per obligation: status-per-scope (kind status-only) or +// without-faults-leaves-doc (kind WithoutFaults) for what every member answers, the member left out throwing, and every operation; and, +// where MapControllers()'s builder sets it, builder-reaches-doc for the operations too. using Probes.Faults; using Probes.Shapes; namespace Probes; @@ -20,7 +22,14 @@ public static Spec Spec(Probes.Shapes.Row r) { var statusOnly = r["kind"] == "status-only"; var set = statusOnly ? new Decl("HFault", "Denied", 451) : new Decl("HFault", "Down", 0); - var s = new Spec { Errors = [FxErr.E("Missing"), FxErr.E("Denied"), FxErr.E("Down")] }; + var kind = statusOnly ? "status-per-scope" : "without-faults-leaves-doc"; + var builder = r["setter"] == "MapControllers().WithFaultStatus"; + var s = new Spec + { + Errors = [FxErr.E("Missing"), FxErr.E("Denied"), FxErr.E("Down")], + Against = Baseline.Docs("CHANGELOG.md#2.1.0-fixed"), + Of = (aspect, _) => builder && Aspect.Doc.Contains(aspect) ? [kind, "builder-reaches-doc"] : [kind], + }; switch (r["setter"]) { case "MapControllers().WithFaultStatus": diff --git a/tools/probe/corpus/probes/C16OptionsAgreementProbe.cs b/tools/probe/corpus/probes/C16OptionsAgreementProbe.cs index f50e0ce4..dbe824c1 100644 --- a/tools/probe/corpus/probes/C16OptionsAgreementProbe.cs +++ b/tools/probe/corpus/probes/C16OptionsAgreementProbe.cs @@ -1,4 +1,4 @@ -// Claim C16-options-agreement (src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says, Register the same on both option sets). The check is SchemaChecks.C16 in harness/SchemaClaim.cs. +// Claim C16-options-agreement (src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says, Register the same on both option sets). Its checks, one per obligation, are SchemaChecks.C16 in harness/SchemaClaim.cs. using Probes.Schema; using Probes.Shapes; namespace Probes; diff --git a/tools/probe/corpus/probes/C21OpenapiSchemaParityProbe.cs b/tools/probe/corpus/probes/C21OpenapiSchemaParityProbe.cs index 0d955cc9..9a3f3e57 100644 --- a/tools/probe/corpus/probes/C21OpenapiSchemaParityProbe.cs +++ b/tools/probe/corpus/probes/C21OpenapiSchemaParityProbe.cs @@ -11,9 +11,10 @@ // endpoint of the row's http.binding) has the parameters, request body and 200 response of its twin with the wrapped type, every // reference resolved, so a component compares by its content. Only the schemas are compared, never whether a parameter is required; // a class value object is compared with the wrapped type's Nullable, and where nullability is spelled two ways the markers go -// (schema:* notes). Not promised, and excluded from the rows: a closed set (a string enum), and a converter of the application's own for -// the value object or the wrapped type (claims.json not-promised). -// Expected: no failure for any row; a document ASP.NET alone refuses the same way has nothing to compare. +// (schema:* notes). Not promised: a closed set (a string enum), excluded from the rows; and a converter of the application's own for +// the value object or the wrapped type, declared inapplicable on every obligation (claims.json), so no row is generated with one. +// Expected: no failure for any row; a document ASP.NET alone refuses the same way has nothing to compare. A difference a not-promised entry +// names ends its check as that citation (Cite.NotPromised), and a nullable class value object without a nullable twin as inconclusive. using Probes.Shapes; namespace Probes; public sealed class C21OpenapiSchemaParityProbe @@ -35,12 +36,6 @@ static IEnumerable Obligations(Row r) if (!any) yield return "the-underlying-types-schema"; } - static async Task Check(Row r) - { - var e = Registry.Of(r); - string? failed = null; - foreach (var obligation in Obligations(r).ToList()) - failed ??= await r.Check(obligation, Baseline.Twin, _ => HttpBaseline.Schema(r, e)); - return failed; - } + /// Every obligation the row selects is checked, on one host, so a seed is seen by its own obligation's check whatever the others find. + static Task Check(Row r) => HttpBaseline.Schema(r, Registry.Of(r), Obligations(r).ToList()); }