diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..aabf085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +### Fixed + +- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md new file mode 100644 index 0000000..d8baeb1 --- /dev/null +++ b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md @@ -0,0 +1,26 @@ +--- +verified: 2026-10-31 +pins: + - Microsoft.AspNetCore.OpenApi 10.0.12 + - sdk 10.0.4xx +source: System.Text.Json in the .NET 10 runtime, the contract of a Nullable from the reflection resolver and a JSON null read into T? as a property, an element and the root, over a T whose converter handles null, measured; and the schemas Microsoft.AspNetCore.OpenApi hands its schema transformers below a Nullable>, measured in PatchSchemaTests +turned-on: the fix of issue 232 (pull request 237), where a nullable patch is described as its argument admitting null but not as a merge-patch field, the container check reads the kind before the element type, and the argument's elements below a nullable patch are skipped +test: test/CodoMetis.TypeKit.Facts/KB057.cs +--- + +# KB-057. A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does + +The contract System.Text.Json gives a `Nullable`: + +- has `Kind` `None` and `ElementType` `T`, for a built-in `T` and one with a converter of its own; `T`'s own + contract has no element type; +- reads a JSON null as null, without calling `T`'s converter, even one whose `HandleNull` is true. That holds as a + property, as an element (`List`) and at the root. A plain `T` hands the same null to its converter. + +So a `T?` property the request sets to null reads the same as one it leaves out. For `Patch?` that means a +null is never the `Null` state, and a request can't clear the field through it. + +Microsoft.AspNetCore.OpenApi walks a schema's `items` and `additionalProperties` with the contract's element type, +whatever its kind. Below a nullable schema it hands them to every schema transformer as `T`: the numbers of a +`Patch?` reached the patch transformer as a `Patch` and gained a null. `PatchSchemaTests` holds that +consequence (its guard's seeds), and this entry's test the serializer's part. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index c9e4b6f..9177e43 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -85,3 +85,4 @@ test and later for the harness's `Baseline.Fact`. | [KB-052](KB-052-sql-server-takes-no-max-of-a-bit.md) | SQL Server takes neither `MAX` nor `MIN` of a `bit` | SQL Server 2022, EF SqlServer 10.0.12 | `KB052`, with Docker | | [KB-053](KB-053-greatest-and-least-need-compatibility-level-160.md) | EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 | EF SqlServer 10.0.12 | `KB053` | | [KB-054](KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md) | `datetime2` keeps every tick of a `DateTime` and reads it back with `Kind` Unspecified | SQL Server 2022, EF SqlServer 10.0.12 | `KB054`, with Docker | +| [KB-057](KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md) | A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does | OpenApi 10.0.12, SDK 10.0.4xx | `KB057` | diff --git a/design/patch.md b/design/patch.md index 5fc4981..9f07029 100644 --- a/design/patch.md +++ b/design/patch.md @@ -59,7 +59,7 @@ names what is missing. | Where | How | Held by | |---|---|---| -| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0 | `PatchSchemaTests` | +| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0; each element of a list or a dictionary of patches as a property is, in 2.0 too; a nullable patch admits the same, without the merge-patch description, since the serializer reads its null as null | `PatchSchemaTests` | | Testing | Assertions per state that hand back the value | `PatchAssertionTests` | | Serilog | Logs its state only | the Serilog tests | | EF Core | Not mapped: a patch is the shape of a request, applied to an entity with its own types | — | diff --git a/docs/merge-patches.md b/docs/merge-patches.md index d8b5a38..7ae87ea 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -194,6 +194,14 @@ OpenAPI 3.1 and 3.0: A value object inside a patch is described through its underlying type, so `email` is a `string`. +A patch inside a list or a dictionary is described the same way, element by element. So a +`Dictionary>`, where a request sets a key with a value and clears it with `null`, +is an object whose values are a string or `null`. + +A nullable `Patch?` accepts the same values. But System.Text.Json reads a `null` there as a +`null` patch, the same as a property the request leaves out, so you can't tell a clear from no change. +Use `Patch` for a field a request can clear. + **Good to know** - **This isn't ASP.NET Core's JSON Patch** (RFC 6902, `application/json-patch+json`), which is a list diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 17865cc..a220d4e 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -7,7 +8,8 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// A property in the document: its argument's schema, inlined, required only /// where the serializer requires it, and admitting null, since the Null state is part of the wire format whatever the -/// argument is. +/// argument is. The same for each element of a list or a dictionary of patches, and for a nullable patch, which the +/// serializer reads as the argument or null but not as a merge-patch field (). /// /// /// @@ -25,6 +27,14 @@ namespace CodoMetis.TypeKit.AspNetCore; /// internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer { + /// + /// The argument's elements below a nullable patch's schema, which ASP.NET visits again as the patch: the serializer + /// gives a an element type, T, and ASP.NET visits items and + /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null + /// (KB-057). + /// + private static readonly ConditionalWeakTable ArgumentElements = new(); + /// A patch is inlined where it is used, never hoisted into a component of its own. public static void Apply(OpenApiOptions options) { @@ -32,7 +42,17 @@ public static void Apply(OpenApiOptions options) options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type); } - private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + private static bool IsPatch(Type type) => Argument(type) is not null; + + /// + /// The argument of a , or of a nullable one, whose JSON is the same values or null. The serializer + /// reads a null into a nullable patch as null, never the patch's Null state, as it leaves an absent property: so a + /// nullable patch admits null without being described as a merge-patch field (KB-057). + /// + private static Type? Argument(Type type) => + (Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) + ? patch.GetGenericArguments()[0] + : null; /// /// The argument's schema, as it is, with null admitted beside every value it admits. @@ -89,11 +109,23 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC } } - if (!IsPatch(info.Type)) return; + // A container of patches arrives without its element, as one of value objects does: ASP.NET leaves out + // items or additionalProperties for a converter-backed element before any transformer runs. The patch's + // own schema is put back, only where it is missing; asked for, it is filled below, as a property's is. + // The kind first: the serializer gives a Nullable an element type too, T. + if (info.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary && info.ElementType is { } element && IsPatch(element)) + { + if (info.Kind == JsonTypeInfoKind.Enumerable && schema.Items is null) + schema.Items = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + else if (info.Kind == JsonTypeInfoKind.Dictionary && schema.AdditionalProperties is null) + schema.AdditionalProperties = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + return; + } + + if (Argument(info.Type) is not { } argument || ArgumentElements.TryGetValue(schema, out _)) return; // What the argument is described as comes first, the host's word or a converter's that replaces a // value object's generated one, as it does where the argument is a property of its own. - var argument = info.Type.GetGenericArguments()[0]; // The argument is held by the patch alone, where ASP.NET asks no transformer of it: a document and a // controller that describe it differently are told apart here. DescribedOptionsAgreement.ThrowIfDisagreeing(context.ApplicationServices, argument, info.Options); @@ -106,6 +138,13 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC schema.Properties?.Clear(); AdmitNull(schema, inner); + if (Nullable.GetUnderlyingType(info.Type) is not null) + { + foreach (var below in new[] { schema.Items, schema.AdditionalProperties }) + if (below is not null) ArgumentElements.AddOrUpdate(below, argument); + return; + } + schema.Description = "A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it." + (inner.Description is null ? "" : " " + inner.Description); } diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 231481f..97b8d98 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,6 +150,10 @@ described as `T`'s schema: A value object inside a patch is described through its underlying type. That's also what a host on source-generated JSON can do, because the patch's converter hides the value object from the context. +Each element of a list, an array or a dictionary of patches is described as a patch property is. A +nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: +a `null` there reads as a `null` patch, the same as a property left out. + **A described type.** 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. The same goes for a converter a library registers for diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 7fe4c2f..fc73ee6 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -48,22 +48,64 @@ public sealed record Square(int Side) : Shape; /// Described by the host as a string or a number, two branches that each refuse null. public sealed record Either(string Value); - /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all. + /// + /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all, and the + /// containers, whose elements are a schema of their own or a component's. + /// public sealed record KindsPatch( Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, - Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either); + Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either, + Patch LinesList, Patch> Counts); /// The same arguments, unpatched: what each value is judged against. public sealed record Kinds( string Name, int Quantity, ProbeCode Code, ProbeId Id, Lines Lines, - Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either); + Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either, + Lines[] LinesList, Dictionary Counts); + + /// + /// Every place a patch can sit, beside a patch property of the same argument: an element of a list, an array or a + /// dictionary, a nullable element, an element of a list in a dictionary, and a nullable patch property. + /// + public sealed record PatchPositions( + Patch Property, + List> List, + Patch[] Array, + Dictionary> Map, + IReadOnlyDictionary> ReadOnlyMap, + List?> NullableElements, + Dictionary>> Nested, + Patch? Nullable) where T : notnull; + + /// + /// The patch in each position of other than the property itself, and whether it is a + /// nullable patch, which the serializer reads as null for a null, not as the Null state. + /// + private static readonly (string Position, Func Patch, bool Nullable)[] Positions = + [ + ("list", schema => schema["items"], false), + ("array", schema => schema["items"], false), + ("map", schema => schema["additionalProperties"], false), + ("readOnlyMap", schema => schema["additionalProperties"], false), + ("nullableElements", schema => schema["items"], true), + ("nested", schema => schema["additionalProperties"]?["items"], false), + ("nullable", schema => schema, true), + ]; + + [Microsoft.AspNetCore.Mvc.ApiController] + public sealed class PositionsController : Microsoft.AspNetCore.Mvc.ControllerBase + { + [Microsoft.AspNetCore.Mvc.HttpPatch("/mvc/positions/guid")] + public int Patch([Microsoft.AspNetCore.Mvc.FromBody] PatchPositions positions) => 0; + } public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; - private static Task DocumentAsync(OpenApiSpecVersion version) => + private static Task DocumentAsync(OpenApiSpecVersion version, string placement = "components") => ProbeHost.DocumentAsync(openApi => { - openApi.AddTypeKit(); + if (placement == "inlined by the host") openApi.CreateSchemaReferenceId = _ => null; + openApi.AddTypeKit(typeKit => typeKit.InlineValueObjects = placement == "InlineValueObjects"); openApi.AddSchemaTransformer((schema, context, _) => { if (context.JsonTypeInfo.Type != typeof(Either)) return Task.CompletedTask; @@ -77,9 +119,41 @@ private static Task DocumentAsync(OpenApiSpecVersion version) => { app.MapPatch("/patched/{id}", int (int id, OrderPatch patch) => 0); app.MapPost("/control", int (Control control) => 0); - app.MapPatch("/kinds", int (KindsPatch patch) => 0); - app.MapPost("/kinds", int (Kinds kinds) => 0); - }); + MapPositions(app, "name"); + MapPositions(app, "quantity"); + MapPositions(app, "code"); + MapPositions(app, "id"); + MapPositions(app, "lines"); + MapPositions(app, "colour"); + MapPositions(app, "status"); + MapPositions(app, "size"); + MapPositions(app, "numbers"); + if (placement != "inlined by the host") + { + app.MapPatch("/kinds", int (KindsPatch patch) => 0); + app.MapPost("/kinds", int (Kinds kinds) => 0); + MapPositions(app, "shape"); + } + MapPositions(app, "anything"); + MapPositions(app, "either"); + MapPositions(app, "linesList"); + MapPositions>(app, "counts"); + }, controllers: [typeof(PositionsController)]); + + /// The positions of a patch of , in a body of their own, and a list of patches as the whole body. + private static void MapPositions(WebApplication app, string key) where T : notnull + { + app.MapPatch($"/positions/{key}", int (PatchPositions positions) => 0); + app.MapPatch($"/positions/{key}/body", int (List> body) => 0); + } + + /// A request body's schema, in 3.x the content's and in 2.0 the body parameter's. + private static JsonNode Body(OpenApiJson document, string path) + { + var operation = document.Root["paths"]?[path]?["patch"] ?? throw new InvalidOperationException($"No operation patch {path}."); + return operation["requestBody"]?["content"]?["application/json"]?["schema"] + ?? operation["parameters"]!.AsArray().Single(parameter => parameter!["in"]!.GetValue() == "body")!["schema"]!; + } /// For each property, values its argument accepts and values it refuses, as JSON. private static readonly (string Property, string[] Values)[] Values = @@ -96,6 +170,8 @@ private static readonly (string Property, string[] Values)[] Values = ("shape", ["{\"$type\":\"circle\",\"radius\":1}", "\"x\"", "1"]), ("anything", ["1", "\"x\"", "{}", "[]"]), ("either", ["\"x\"", "1", "true"]), + ("linesList", ["[{\"count\":1}]", "[{}]", "{\"count\":1}"]), + ("counts", ["{\"a\":1}", "{\"a\":\"x\"}", "[1]"]), ]; /// @@ -126,6 +202,111 @@ public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_a } } + public static TheoryData Hosts() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi2_0 }) + foreach (var placement in new[] { "components", "InlineValueObjects", "inlined by the host" }) + data.Add(version, placement); + + return data; + } + + /// + /// A patch anywhere else is described as a patch property is: an element of a list, an array or a dictionary, one in + /// a list in a dictionary, and the element of a list that is the whole body. ASP.NET left the elements out, as it does + /// for any converter-backed element, so a client generator typed a map of patches as a map of anything. A nullable patch, + /// a property or an element, which was an empty component and required, is the same schema without the merge-patch + /// description, since a null there reads as null. Over every argument of the catalogue, in each version, wherever value + /// objects are described, in a minimal API and an MVC controller. + /// + [Theory] + [MemberData(nameof(Hosts))] + public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(OpenApiSpecVersion version, string placement) + { + var document = await DocumentAsync(version, placement); + + // ASP.NET refuses a polymorphic type with no reference id, a plain property of it too (an ArgumentNullException for + // the referenceId, measured with Microsoft.AspNetCore.OpenApi 10.0), so a host that inlines everything has no shape. + var arguments = Values.Select(value => value.Property).Where(property => placement != "inlined by the host" || property != "shape"); + foreach (var path in arguments.Select(property => "/positions/" + property).Append("/mvc/positions/guid")) + { + var positions = document.Resolve(Body(document, path)); + var property = document.Expand(positions["properties"]!["property"])!; + property["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field", customMessage: $"{path}: the property is the patch's"); + + foreach (var (position, patch, nullable) in Positions) + { + var schema = document.Expand(patch(positions["properties"]![position]!)) + .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}"); + if (!nullable) + { + schema.ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + continue; + } + + schema["description"].ShouldBeNull($"{path} '{position}': a null in a nullable patch reads as null, not as a clear"); + WithoutDescription(schema).ShouldDescribeTheSameAs(WithoutDescription(property), $"{path} '{position}'"); + } + + var required = positions["required"]?.AsArray().Select(node => node!.GetValue()) ?? []; + required.ShouldNotContain("nullable", $"{path}: a nullable patch is optional as a patch is"); + + if (path.StartsWith("/mvc", StringComparison.Ordinal)) continue; + document.Expand(Body(document, path + "/body")["items"]).ShouldNotBeNull($"{path}/body").ShouldDescribeTheSameAs(property, $"{path}/body"); + } + } + + /// + /// What the document says of a map and a list of patches is what the endpoint reads: a value sets a key and a null + /// clears it. A nullable patch reads a null as null, as it reads a property the request leaves out, which is why the + /// document does not call it a merge-patch field. + /// + [Fact] + public async Task A_map_of_patches_reads_a_value_and_a_null_as_the_document_says() + { + await using var host = await ProbeHost.StartAsync(openApi => openApi.AddTypeKit(), endpoints: app => + app.MapPatch("/read", string (PatchPositions patch) => string.Join(" ", + patch.Map.OrderBy(entry => entry.Key).Select(entry => $"{entry.Key}={entry.Value.State}") + .Concat(patch.List.Select(element => element.State.ToString())) + .Append(patch.Nullable?.State.ToString() ?? "none")))); + + using var response = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{"a":"x","b":null},"list":["y",null],"nullable":null}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + using var leftOut = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{},"list":[]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null none"); + (await leftOut.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("none"); + } + + /// + /// The element of a container of patches accepts null and otherwise exactly what its argument accepts, judged by a + /// JSON Schema validator over the catalogue above, as a patch property is. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_in_a_container_accepts_null_and_otherwise_exactly_what_its_argument_accepts(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + foreach (var (property, values) in Values) + { + var positions = document.Resolve(Body(document, "/positions/" + property)); + var argument = Validator(document, document.Property(nameof(Kinds), property), version); + + foreach (var (position, select, _) in Positions) + { + var schema = select(positions["properties"]![position]!) ?? throw new ShouldAssertException($"'{property}' '{position}' has no element schema"); + var patch = Validator(document, schema, version); + + patch("null").ShouldBeTrue($"'{property}' '{position}' should accept null: {schema.ToJsonString()}"); + foreach (var value in values) + patch(value).ShouldBe(argument(value), $"'{property}' '{position}' should judge {value} as its argument does: {schema.ToJsonString()}"); + } + } + } + /// /// A validator for a property's schema, its references to components inlined, and in 3.0 its /// nullable read as 3.0.3 defines it: null added to a type the same schema states, and @@ -251,6 +432,13 @@ public async Task A_patch_parameter_defaulted_with_default_breaks_the_document() refusal.StackTrace.ShouldNotBeNull().ShouldContain(nameof(System.Text.Json.Schema.JsonSchemaExporter), customMessage: "the exporter throws, not the converter"); } + private static JsonNode WithoutDescription(JsonNode schema) + { + var copy = schema.DeepClone().AsObject(); + copy.Remove("description"); + return copy; + } + private static bool AdmitsNull(JsonNode schema, OpenApiSpecVersion version) => version == OpenApiSpecVersion.OpenApi3_1 ? schema["type"] is JsonArray types && types.Any(type => type!.GetValue() == "null") diff --git a/test/CodoMetis.TypeKit.Facts/KB057.cs b/test/CodoMetis.TypeKit.Facts/KB057.cs new file mode 100644 index 0000000..122348e --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB057.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-057: a Nullable<T> contract has T as its element type, though its kind is +/// , and a JSON null read into a T? is null without asking T's converter, +/// even one that handles null: as a property, an element and the root alike, where a plain T hands the null to it. +/// +public sealed class KB057 +{ + /// A struct whose converter reads a null as a state of its own, as a merge-patch field's does. + [JsonConverter(typeof(MarkedConverter))] + public readonly record struct Marked(bool WasNull); + + private sealed class MarkedConverter : JsonConverter + { + public override bool HandleNull => true; + + public override Marked Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var wasNull = reader.TokenType == JsonTokenType.Null; + reader.Skip(); + return new Marked(wasNull); + } + + public override void Write(Utf8JsonWriter writer, Marked value, JsonSerializerOptions options) => writer.WriteNullValue(); + } + + public sealed record Holder(Marked Plain, Marked? Maybe); + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + [Fact] + public void A_nullable_contract_has_the_underlying_type_as_its_element_type() + { + var nullable = Options.GetTypeInfo(typeof(Marked?)); + + nullable.Kind.ShouldBe(JsonTypeInfoKind.None); + nullable.ElementType.ShouldBe(typeof(Marked)); + Options.GetTypeInfo(typeof(int?)).ElementType.ShouldBe(typeof(int), "the same for a built-in type"); + Options.GetTypeInfo(typeof(Marked)).ElementType.ShouldBeNull("the underlying type itself has none"); + } + + [Fact] + public void A_null_read_into_a_nullable_never_reaches_a_converter_that_handles_null() + { + var holder = JsonSerializer.Deserialize("""{"plain":null,"maybe":null}""", Options)!; + holder.Plain.WasNull.ShouldBeTrue("a plain T hands the null to its converter"); + holder.Maybe.ShouldBeNull("a T? property reads it as null"); + + JsonSerializer.Deserialize("""{"plain":null}""", Options)!.Maybe.ShouldBeNull("the same as a property left out"); + JsonSerializer.Deserialize>("[null]", Options).ShouldBe([null], "an element too"); + JsonSerializer.Deserialize("null", Options).ShouldBeNull("and the root"); + JsonSerializer.Deserialize("null", Options).WasNull.ShouldBeTrue(); + } +} diff --git a/test/oracles/axes.md b/test/oracles/axes.md index 05e1cbf..3f6b388 100644 --- a/test/oracles/axes.md +++ b/test/oracles/axes.md @@ -32,8 +32,8 @@ footprint, it runs with the suite. |---|---|---|---|---| | JSON | the host's options | defaults, Web, a naming policy, a dictionary-key policy, number handling, a host converter | `JsonParityTests` | reference preservation beside a dictionary key | | JSON | the wrapped type | every built-in the generators wrap, nullable or not | `JsonParityTests`, `JsonTests` | | -| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests` | a withheld fault name under 2.0 | -| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | +| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests`, `PatchSchemaTests` | a withheld fault name under 2.0 | +| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests`, `PatchSchemaTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | | HTTP | the host | minimal API, MVC with `[ApiController]`, MVC without | `MvcBindingTests`, `FaultStatusScopeTests` | | | EF | the provider | SQLite, Npgsql, SQL Server | `KeyGenerationVariantTests`, `QueryTranslationVariantTests` | | | EF | where a key sits | `KeyShapes` (`test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/`) | `KeyGenerationVariantTests` | | diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..14514cf 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3644,6 +3644,19 @@ "PatchSchemaTests" ] }, + { + "id": "P06-element-by-element", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "A patch inside a list or a dictionary is described the same way, element by element.", + "claim": "A patch inside a list or a dictionary is described the same way, element by element.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P06-inlined", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", @@ -3657,6 +3670,58 @@ "PatchSchemaTests" ] }, + { + "id": "P06-map-of-patches", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "claim": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-nullable-patch-reads-null-as-null", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "claim": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-readme-containers", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "claim": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-readme-nullable-patch", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "claim": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P06-through-the-underlying-type", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document",