diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ee4749..f935471a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ section is the GitHub release's notes. - OpenAPI: the default of a parameter of a type a converter describes, or of a value object over one, such as `[DefaultValue(typeof(decimal), "1.5")]` under `DecimalStringJsonConverter`, is written as ASP.NET writes it without the converter, `1.5`, not `"1.5"` ([Parameters aren't JSON](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). - OpenAPI: a non-abstract `[JsonPolymorphic]` base's own branch (`{Base}Base`) describes its value objects, patches, closed sets and described types as the derived types' branches do, where it had `{}` and a list without `items`, in OpenAPI 3.1, 3.0 and 2.0 ([What the document says](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). - OpenAPI: a list or dictionary of a nullable value object whose converter a described one replaces, such as `List` under a converter that writes cents, admits `null` in its elements where value objects are components ([A described type](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). +- OpenAPI: a nullable value-object property where value objects are components is the component or `null` wherever the underlying type's property admits `null`: get-only, a field, typed by a type parameter, under `[MaybeNull]` or `[DisallowNull]`, or without nullable annotations, where it was the bare reference; under OpenAPI 2.0 it's ASP.NET's `x-nullable` without the reference there too, as a settable one already was ([What the document says](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). - OpenAPI: a nullable value object is its wrapped type at the same nullability in every position: an MVC `[FromBody] Quantity?` admits `null` inside its schema as `int?` does, and, where value objects are inlined, a query, header or form parameter no longer admits `null`, nor does a body, response or element of a nullable struct over `string` or `Uri` ([What the document says](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). - OpenAPI: a nullable value object over an enum written by name lists `null` among its values as a route, query, header or form parameter, as the enum's own `DayOfWeek?` does; where value objects alone are inlined, a nullable one over an enum no longer puts a second null-or inside a body's or a response's, and as an element or a dictionary value keeps its type in OpenAPI 2.0 ([What the document says](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). - OpenAPI: a problem response an endpoint declares as `application/json`, such as `Produces(404)` or `[ProducesResponseType(404)]`, describes `fault` and `rule` and names its problem types, and keeps its media type; so does one of a type derived from `ProblemDetails`, such as `ProducesValidationProblem(404)` ([A Result as a response](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#a-result-as-a-response)). diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs index f83ec7eb..6078f794 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs @@ -79,7 +79,8 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC // Written by a converter the options put in charge, not the generated one: not the wrapped type's. if (DescribedSchemaTransformer.Replaced(info.Type, info.Options)) return; - // Where ASP.NET makes the value object a component, it adds the null around the reference itself. + // Where ASP.NET makes the value object a component, it adds the null around the reference itself (marked below where + // it would not). // Inlined, the schema is this position's own, and the null goes in it where ASP.NET's exporter puts // it into the wrapped type's schema in the same position: for a nullable property of that type, and // elsewhere for the Nullable of a wrapped value type, a parameter outside the body included. Not at @@ -97,8 +98,17 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC DescribedSchemaTransformer.ParameterDefault(schema, info.Type, info.Options); } - var position = !inline && SchemaReferenceId(info, context) is not null ? NullablePosition.None - : context.JsonPropertyInfo is { IsGetNullable: true } or { IsSetNullable: true } ? NullablePosition.Property + var component = !inline && SchemaReferenceId(info, context) is not null; + var nullableProperty = context.JsonPropertyInfo is { IsGetNullable: true } or { IsSetNullable: true }; + + // ASP.NET puts its null around the reference only for a property it reads as nullable through NullabilityInfoContext, + // a settable one annotated: not for a get-only property, a field or an oblivious reference type, whose JSON admits null + // as the serializer's contract says, and whose wrapped type's property keeps its null. So the property is marked as ASP.NET + // marks the others, under its own key (OpenApiConstants.NullableProperty, internal, as decision 106 reads x-schema-id). + if (component && nullableProperty) MarkNullableProperty(schema); + + var position = component ? NullablePosition.None + : nullableProperty ? NullablePosition.Property : Nullable.GetUnderlyingType(info.Type) is null || !wrapped.IsValueType ? NullablePosition.None : IsParameterOutsideTheBody(context) ? NullablePosition.ParameterOutsideTheBody : NullablePosition.Root; @@ -273,6 +283,16 @@ private static void Inline(OpenApiSchema schema) if (metadata.TryGetValue("x-ref-default", out var value) && metadata.Remove("x-ref-default") && value is JsonNode defaultValue) schema.Default ??= defaultValue; } + /// + /// Has ASP.NET write the property as null or the reference to the value object's component, as it writes a property it + /// reads as nullable itself. It reads the mark from the schema's metadata, under a key of its own + /// (OpenApiConstants.NullableProperty, internal, set by ApplyNullabilityContextInfo and read by + /// ResolveReferenceForSchema; measured with Microsoft.AspNetCore.OpenApi 10.0.12). OpenAPI 2.0 writes it as its + /// allOf: [{x-nullable: true}], without the reference, as for every nullable property of a component (KB-026). + /// + private static void MarkNullableProperty(OpenApiSchema schema) => + (schema.Metadata ??= new Dictionary())["x-is-nullable-property"] = true; + /// /// Whether a schema of visited with is the parameter's own, /// rather than an element's below it (decimal under decimal[]) or the type a value object wraps: diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentNullablePropertyTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentNullablePropertyTests.cs new file mode 100644 index 00000000..393af919 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentNullablePropertyTests.cs @@ -0,0 +1,315 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.CompilerServices; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A nullable value object that is a component admits null as a property wherever the serializer's contract says the property +/// is nullable, as its wrapped type's property does there: a get-only property bound by the constructor, a field under +/// IncludeFields, and a reference-type property in #nullable disable code (decision 36). Each is written as +/// ASP.NET writes a settable annotated property of the same value object beside it, oneOf: [null, component], and +/// under OpenAPI 2.0 as its allOf: [{x-nullable: true}] without the reference (KB-026). +/// +/// +/// ASP.NET puts the null around a component only for a property whose NullabilityInfoContext write state is nullable, +/// which a get-only property (unknown), a field (not a property) and an oblivious reference type are not. The wrapped type, +/// which is no component, gets its null from JsonPropertyInfo in all three. Without the mark the value object's +/// property was the bare reference where the wrapped type's admitted null (C21OpenapiSchemaParityProbe, confirmed by the +/// code-analyst run of 2026-10-18). +/// +public sealed class ComponentNullablePropertyTests +{ + private static readonly ConcurrentDictionary> Documents = new(); + + public static TheoryData Positions() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi2_0 }) + { + data.Add(version, "get-only", "GetOnlyTimeSpan"); + data.Add(version, "field", "FieldTimeSpan"); + data.Add(version, "oblivious", "ObliviousString"); + } + + return data; + } + + [Theory] + [MemberData(nameof(Positions))] + public async Task A_nullable_value_object_component_admits_null_where_its_wrapped_type_does(OpenApiSpecVersion version, string position, string twin) + { + var document = await Documents.GetOrAdd(version, static v => ProbeHost.DocumentAsync( + openApi => openApi.AddTypeKit(), + json: json => json.SerializerOptions.IncludeFields = true, + version: v, + endpoints: app => + { + app.MapPost("/get-only/vo", ([FromBody] GetOnlyDuration v) => 0); + app.MapPost("/get-only/twin", ([FromBody] GetOnlyTimeSpan v) => 0); + app.MapPost("/field/vo", ([FromBody] ComponentField v) => 0); + app.MapPost("/field/twin", ([FromBody] FieldTimeSpan v) => 0); + app.MapPost("/oblivious/vo", ([FromBody] ObliviousLabel v) => 0); + app.MapPost("/oblivious/twin", ([FromBody] ObliviousString v) => 0); + })); + + var holder = Body(document, version, $"/{position}/vo"); + var valueObject = Property(document, holder, "p"); + // ASP.NET's own form for a nullable property of the component, settable and annotated, beside it. + var asAspNetWritesIt = Property(document, holder, "q"); + var wrapped = Property(document, Body(document, version, $"/{position}/twin"), "p"); + + if (version == OpenApiSpecVersion.OpenApi2_0) + (asAspNetWritesIt["allOf"]?[0]?["x-nullable"]?.GetValue() == true && asAspNetWritesIt["$ref"] is null).ShouldBeTrue(asAspNetWritesIt.ToJsonString()); + else + asAspNetWritesIt["oneOf"]?[1]?["$ref"].ShouldNotBeNull(asAspNetWritesIt.ToJsonString()); + + valueObject.ShouldDescribeTheSameAs(asAspNetWritesIt, $"{position}: the value object's property against its settable annotated sibling"); + + // The wrapped type admits null there, and where the null-or keeps the reference, it is the component's content or null. + JsonNode.DeepEquals(NullablePositionTests.WithoutNull(wrapped), wrapped).ShouldBeFalse($"{twin}'s property admits no null: {wrapped.ToJsonString()}"); + if (version != OpenApiSpecVersion.OpenApi2_0) + NullablePositionTests.WithoutNull(Resolve(document, valueObject["oneOf"]![1]!)).ShouldDescribeTheSameAs(NullablePositionTests.WithoutNull(wrapped), $"{position} against {twin}"); + } + + /// + /// The input class: a property's nullability as NullabilityInfoContext reads it (ASP.NET's, for a component) against + /// JsonPropertyInfo (the serializer's, and ASP.NET's for an inlined schema). Each variant is a value object's member + /// and its wrapped type's, at the same declared nullability; the reference forms are the settable members beside them. + /// + private static readonly (string Name, Type ValueObject, Type Wrapped)[] Variants = + [ + // A struct value object, made nullable or not. + ("settable", typeof(NvSettable), typeof(NvSettable)), + ("get-only", typeof(NvGetOnly), typeof(NvGetOnly)), + ("computed", typeof(NvComputed), typeof(NvComputed)), + ("field", typeof(NvField), typeof(NvField)), + ("readonly-field", typeof(NvReadOnlyField), typeof(NvReadOnlyField)), + ("init", typeof(NvInit), typeof(NvInit)), + ("required-init", typeof(NvRequiredInit), typeof(NvRequiredInit)), + ("positional", typeof(NvPositional), typeof(NvPositional)), + ("private-set-included", typeof(NvIncluded), typeof(NvIncluded)), + ("struct-holder-get-only", typeof(NvStructGetOnly), typeof(NvStructGetOnly)), + // Types of their own: ASP.NET names the component of NvSettable and of NvSettable alike, and + // describes both as the first it meets. + ("settable-not-nullable", typeof(NvSettableNotNullable), typeof(NvSettableNotNullable)), + ("get-only-not-nullable", typeof(NvGetOnlyNotNullable), typeof(NvGetOnlyNotNullable)), + ("field-not-nullable", typeof(NvFieldNotNullable), typeof(NvFieldNotNullable)), + // A class value object, annotated, oblivious, or under a nullability attribute. + ("label-settable", typeof(NvLabelSettable), typeof(NvStringSettable)), + ("label-get-only", typeof(NvLabelGetOnly), typeof(NvStringGetOnly)), + ("label-field", typeof(NvLabelField), typeof(NvStringField)), + ("label-required-init", typeof(NvLabelRequiredInit), typeof(NvStringRequiredInit)), + ("label-positional", typeof(NvLabelPositional), typeof(NvStringPositional)), + ("label-oblivious", typeof(NvLabelOblivious), typeof(NvStringOblivious)), + ("label-oblivious-get-only", typeof(NvLabelObliviousGetOnly), typeof(NvStringObliviousGetOnly)), + ("label-oblivious-field", typeof(NvLabelObliviousField), typeof(NvStringObliviousField)), + ("label-allow-null", typeof(NvLabelAllowNull), typeof(NvStringAllowNull)), + ("label-maybe-null", typeof(NvLabelMaybeNull), typeof(NvStringMaybeNull)), + ("label-disallow-null", typeof(NvLabelDisallowNull), typeof(NvStringDisallowNull)), + ("label-not-null", typeof(NvLabelNotNull), typeof(NvStringNotNull)), + ("label-not-nullable", typeof(NvLabelNotNullable), typeof(NvStringNotNullable)), + ("label-get-only-not-nullable", typeof(NvLabelGetOnlyNotNullable), typeof(NvStringGetOnlyNotNullable)), + // A class value object typed by a type parameter, whose annotation the reflected property does not carry. + ("label-type-parameter-settable", typeof(NvSettable), typeof(NvSettable)), + ("label-type-parameter-get-only", typeof(NvGetOnly), typeof(NvGetOnly)), + ("label-type-parameter-field", typeof(NvField), typeof(NvField)), + ("label-type-parameter-required-init", typeof(NvRequiredInit), typeof(NvRequiredInit)), + ]; + + private static readonly ConcurrentDictionary<(OpenApiSpecVersion, bool, bool), Task> VariantDocuments = new(); + + public static TheoryData VariantRows() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi2_0 }) + foreach (var components in new[] { true, false }) + foreach (var respectNullableAnnotations in new[] { false, true }) + foreach (var (name, _, _) in Variants) + data.Add(version, components, respectNullableAnnotations, name); + + return data; + } + + [Theory] + [MemberData(nameof(VariantRows))] + public async Task A_value_object_member_admits_null_exactly_where_its_wrapped_type_s_does( + OpenApiSpecVersion version, bool components, bool respectNullableAnnotations, string variant) + { + var document = await VariantDocuments.GetOrAdd((version, components, respectNullableAnnotations), static key => ProbeHost.DocumentAsync( + openApi => + { + openApi.AddTypeKit(); + if (!key.Item2) + openApi.CreateSchemaReferenceId = type => + (Nullable.GetUnderlyingType(type.Type) ?? type.Type).GetCustomAttribute() is not null + ? null + : OpenApiOptions.CreateDefaultSchemaReferenceId(type); + }, + json: json => + { + json.SerializerOptions.IncludeFields = true; + json.SerializerOptions.RespectNullableAnnotations = key.Item3; + }, + version: key.Item1, + endpoints: app => + { + foreach (var (name, valueObject, wrapped) in Variants) + { + MapBody(app, $"/variants/{name}/vo", valueObject); + MapBody(app, $"/variants/{name}/twin", wrapped); + } + })); + + JsonNode Member(string name, string side) => Property(document, Body(document, version, $"/variants/{name}/{side}"), "p"); + bool AdmitsNull(JsonNode schema) => !JsonNode.DeepEquals(NullablePositionTests.WithoutNull(schema), schema); + + var valueObject = Member(variant, "vo"); + var wrapped = Member(variant, "twin"); + var label = variant.StartsWith("label-", StringComparison.Ordinal); + + if (components) + { + // Not vacuous: ASP.NET's two forms differ, and the wrapped type admits null exactly where its declaration says. + JsonNode.DeepEquals(Member(label ? "label-settable" : "settable", "vo"), Member(label ? "label-not-nullable" : "settable-not-nullable", "vo")).ShouldBeFalse(); + AdmitsNull(wrapped).ShouldBe(!variant.EndsWith("-not-nullable", StringComparison.Ordinal), wrapped.ToJsonString()); + + // ASP.NET's own forms for the component: its settable annotated property, and its property that is not nullable. + var reference = AdmitsNull(wrapped) + ? Member(label ? "label-settable" : "settable", "vo") + : Member(label ? "label-not-nullable" : "settable-not-nullable", "vo"); + valueObject.ShouldDescribeTheSameAs(reference, $"{variant}, the wrapped type's {wrapped.ToJsonString()}"); + } + else + { + NullablePositionTests.Inline(document, valueObject)!.ShouldDescribeTheSameAs(NullablePositionTests.Inline(document, wrapped)!, variant); + } + } + + private static void MapBody(WebApplication app, string path, Type type) => + typeof(ComponentNullablePropertyTests).GetMethod(nameof(MapBodyOf), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type).Invoke(null, [app, path]); + + private static void MapBodyOf(WebApplication app, string path) => app.MapPost(path, ([FromBody] T v) => 0); + + private static JsonNode Body(OpenApiJson document, OpenApiSpecVersion version, string path) + { + var operation = document.Root["paths"]?[path]?["post"] ?? throw new InvalidOperationException($"No operation post {path}."); + var body = version == OpenApiSpecVersion.OpenApi2_0 + ? operation["parameters"]!.AsArray().Single(p => p!["in"]!.GetValue() == "body")!["schema"]! + : operation["requestBody"]!["content"]!["application/json"]!["schema"]!; + + return Resolve(document, body); + } + + private static JsonNode Property(OpenApiJson document, JsonNode holder, string name) => + holder["properties"]?[name]?.DeepClone() ?? throw new InvalidOperationException($"No property '{name}' in {holder.ToJsonString()}."); + + private static JsonNode Resolve(OpenApiJson document, JsonNode schema) => + schema["$ref"] is { } reference + ? (document.Root["components"]?["schemas"] ?? document.Root["definitions"])?[reference.GetValue().Split('/')[^1]] + ?? throw new InvalidOperationException($"No schema '{reference}'.") + : schema; +} + +public sealed class GetOnlyDuration(ProbeDuration? p) +{ + public ProbeDuration? P { get; } = p; + + public ProbeDuration? Q { get; set; } +} + +public sealed class GetOnlyTimeSpan(TimeSpan? p) +{ + public TimeSpan? P { get; } = p; + + public TimeSpan? Q { get; set; } +} + +/// A field of the type argument, serialized under IncludeFields. +public sealed class ComponentField +{ + public T P = default!; + + public T Q { get; set; } = default!; +} + +public sealed class FieldTimeSpan +{ + public TimeSpan? P; + + public TimeSpan? Q { get; set; } +} + +#nullable disable +public sealed class ObliviousLabel +{ + public ProbeLabel P { get; set; } +#nullable enable + public ProbeLabel? Q { get; set; } +#nullable disable +} + +public sealed class ObliviousString +{ + public string P { get; set; } +#nullable enable + public string? Q { get; set; } +#nullable disable +} +#nullable restore + +// The variants: the member is p, each beside its wrapped type's (Nv...String... beside Nv...Label...). +public sealed class NvSettable { public T P { get; set; } = default!; } +public sealed class NvGetOnly(T p) { public T P { get; } = p; } +public sealed class NvComputed { public T P => default!; } +public sealed class NvField { public T P = default!; } +public sealed class NvReadOnlyField(T p) { public readonly T P = p; } +public sealed class NvInit { public T P { get; init; } = default!; } +public sealed class NvRequiredInit { public required T P { get; init; } } +public sealed record NvPositional(T P); +public sealed class NvIncluded { [JsonInclude] public T P { get; private set; } = default!; } +public readonly struct NvStructGetOnly(T p) { public T P { get; } = p; } +public sealed class NvSettableNotNullable { public T P { get; set; } = default!; } +public sealed class NvGetOnlyNotNullable(T p) { public T P { get; } = p; } +public sealed class NvFieldNotNullable { public T P = default!; } + +public sealed class NvLabelSettable { public ProbeLabel? P { get; set; } } +public sealed class NvStringSettable { public string? P { get; set; } } +public sealed class NvLabelGetOnly(ProbeLabel? p) { public ProbeLabel? P { get; } = p; } +public sealed class NvStringGetOnly(string? p) { public string? P { get; } = p; } +public sealed class NvLabelField { public ProbeLabel? P; } +public sealed class NvStringField { public string? P; } +public sealed class NvLabelRequiredInit { public required ProbeLabel? P { get; init; } } +public sealed class NvStringRequiredInit { public required string? P { get; init; } } +public sealed record NvLabelPositional(ProbeLabel? P); +public sealed record NvStringPositional(string? P); +public sealed class NvLabelAllowNull { [AllowNull] public ProbeLabel P { get; set; } = ProbeLabel.From("x"); } +public sealed class NvStringAllowNull { [AllowNull] public string P { get; set; } = ""; } +public sealed class NvLabelMaybeNull { [MaybeNull] public ProbeLabel P { get; set; } = ProbeLabel.From("x"); } +public sealed class NvStringMaybeNull { [MaybeNull] public string P { get; set; } = ""; } +public sealed class NvLabelDisallowNull { [DisallowNull] public ProbeLabel? P { get; set; } } +public sealed class NvStringDisallowNull { [DisallowNull] public string? P { get; set; } } +public sealed class NvLabelNotNull { [NotNull] public ProbeLabel? P { get; set; } = ProbeLabel.From("x"); } +public sealed class NvStringNotNull { [NotNull] public string? P { get; set; } = ""; } +public sealed class NvLabelNotNullable { public ProbeLabel P { get; set; } = ProbeLabel.From("x"); } +public sealed class NvStringNotNullable { public string P { get; set; } = ""; } +public sealed class NvLabelGetOnlyNotNullable(ProbeLabel p) { public ProbeLabel P { get; } = p; } +public sealed class NvStringGetOnlyNotNullable(string p) { public string P { get; } = p; } + +#nullable disable +public sealed class NvLabelOblivious { public ProbeLabel P { get; set; } } +public sealed class NvStringOblivious { public string P { get; set; } } +public sealed class NvLabelObliviousGetOnly(ProbeLabel p) { public ProbeLabel P { get; } = p; } +public sealed class NvStringObliviousGetOnly(string p) { public string P { get; } = p; } +public sealed class NvLabelObliviousField { public ProbeLabel P; } +public sealed class NvStringObliviousField { public string P; } +#nullable restore diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/NullablePositionTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/NullablePositionTests.cs index 0a524a7b..6e550fae 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/NullablePositionTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/NullablePositionTests.cs @@ -383,7 +383,7 @@ private static JsonNode Target(OpenApiJson document, string reference) } /// The schema without the null it admits: null out of its type and its values, nullable and a null branch gone. - private static JsonNode WithoutNull(JsonNode schema) + internal static JsonNode WithoutNull(JsonNode schema) { var copy = schema.DeepClone().AsObject(); copy.Remove("nullable"); diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PolymorphicBaseTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PolymorphicBaseTests.cs index 14fd3762..fe827e49 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PolymorphicBaseTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PolymorphicBaseTests.cs @@ -133,6 +133,9 @@ public class PolyAnimal public ProbeAmount? MaybeMoney { get; set; } + // Get-only: ASP.NET's own null around a component's reference needs a setter, so the null is TypeKit's here. + public ProbeCount? Computed => null; + public PolyInner Inner { get; set; } = new(); } @@ -166,6 +169,8 @@ public class PlainAnimal public List Amounts { get; set; } = []; + public int? Computed => null; + public PlainInner Inner { get; set; } = new(); } diff --git a/tools/probe/corpus/harness/HttpBaseline.cs b/tools/probe/corpus/harness/HttpBaseline.cs index 2fe5adae..ad6bd233 100644 --- a/tools/probe/corpus/harness/HttpBaseline.cs +++ b/tools/probe/corpus/harness/HttpBaseline.cs @@ -505,6 +505,14 @@ static bool NullOrReference(JsonNode? n) => static bool OnlyTheNullFlag(JsonObject o) => o.Count == 1 && o["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag && flag["x-nullable"]?.GetValueKind() == JsonValueKind.True; + /// + /// OpenAPI 2.0's nullable property of a component (KB-026): allOf: [{x-nullable: true}], without the reference and so without a + /// type, beside what TypeKit kept of the wrapped type (a format). + /// + static bool TheNullFlagWithoutItsReference(JsonObject o) => + o["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag && flag["x-nullable"]?.GetValueKind() == JsonValueKind.True + && !o.ContainsKey("$ref") && !o.ContainsKey(Hx.ResolvedReference) && !o.ContainsKey("type"); + static bool OnlyItsNull(JsonNode? n) => n switch { JsonObject o => o["schema"] is JsonObject { Count: 1 } schema && schema["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag @@ -664,6 +672,14 @@ string Inputs(JsonNode op) // shows it: without TypeKit, ASP.NET writes the value object's position at the same path the same, where the twin has nothing. if (openApi2 && d.P is null && d.V is not null && At(vo1, d.Keys) is { } same1 && JsonNode.DeepEquals(same1, At(vo3, d.Keys))) { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), d.Path); continue; } + // The same loss told by the row's axes, where side (1) cannot show it: a nullable value object that is a component is a + // nullable property wherever the serializer's contract says so (a get-only or a type parameter's property among them), where + // ASP.NET alone adds the null only for a settable annotated one. Its property is then the null flag without the reference, + // where the twin's keeps its type beside x-nullable. + if (openApi2 && Make.AdmitsNull(e.Vo) && !r.Is("openapi.CreateSchemaReferenceId", "null-inlines-all") && d.V is null + && At(vo3, d.Keys.SkipLast(1)) is JsonObject lost && TheNullFlagWithoutItsReference(lost) + && At(JsonNode.Parse(Sig(doc, po)), d.Keys.SkipLast(1)) is JsonObject kept && kept["x-nullable"]?.GetValueKind() == JsonValueKind.True) + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), d.Path); 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). Side (1) shows it: without TypeKit, the twin's schema there is that null alone. diff --git a/tools/probe/corpus/seeds/C21-openapi-schema-parity-nullable-admits-null-as-the-wrapped-does.patch b/tools/probe/corpus/seeds/C21-openapi-schema-parity-nullable-admits-null-as-the-wrapped-does.patch index bf65bcd3..688f8828 100644 --- a/tools/probe/corpus/seeds/C21-openapi-schema-parity-nullable-admits-null-as-the-wrapped-does.patch +++ b/tools/probe/corpus/seeds/C21-openapi-schema-parity-nullable-admits-null-as-the-wrapped-does.patch @@ -1,21 +1,21 @@ diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs -index f83ec7e..c7b2a33 100644 +index 6078f79..2493ef9 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs -@@ -98,10 +98,8 @@ internal sealed class ValueObjectSchemaTransformer : IOpenApiSchemaTransformer - } +@@ -108,10 +108,8 @@ internal sealed class ValueObjectSchemaTransformer : IOpenApiSchemaTransformer + if (component && nullableProperty) MarkNullableProperty(schema); - var position = !inline && SchemaReferenceId(info, context) is not null ? NullablePosition.None -- : context.JsonPropertyInfo is { IsGetNullable: true } or { IsSetNullable: true } ? NullablePosition.Property + var position = component ? NullablePosition.None +- : nullableProperty ? NullablePosition.Property - : Nullable.GetUnderlyingType(info.Type) is null || !wrapped.IsValueType ? NullablePosition.None - : IsParameterOutsideTheBody(context) ? NullablePosition.ParameterOutsideTheBody - : NullablePosition.Root; -+ : Nullable.GetUnderlyingType(info.Type) is not null || context.JsonPropertyInfo is { IsGetNullable: true } or { IsSetNullable: true } ? NullablePosition.Property ++ : Nullable.GetUnderlyingType(info.Type) is not null || nullableProperty ? NullablePosition.Property + : NullablePosition.None; await DescribeAsync(schema, info.Type, wrapped, outside, position, context, cancellationToken); return; -@@ -119,9 +117,7 @@ internal sealed class ValueObjectSchemaTransformer : IOpenApiSchemaTransformer +@@ -129,9 +127,7 @@ internal sealed class ValueObjectSchemaTransformer : IOpenApiSchemaTransformer // MVC hands a value object bound from the body here too, as the string it would parse. There // ASP.NET's exporter admits null in the schema of the Nullable of a wrapped value type, inside // the null-or ASP.NET puts around a nullable body, and outside the body among its values alone.