From c8b4575fa4501e34966cc06ad796ee467a0be778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 18 Oct 2026 08:56:53 +0200 Subject: [PATCH 1/3] fix: a nullable value object that is a component keeps its null as a get-only property, a field and an oblivious one C21OpenapiSchemaParityProbe found, and the code-analyst confirmed on 2026-10-18 by reproduction and a seeded fix, that a nullable value object written as a component lost its null in three positions where its wrapped twin kept it: a get-only property bound by the constructor, a field under IncludeFields, and a reference-type property in #nullable disable code. The value object's property was the bare $ref; the twin's admitted null. Decision 36 and the AspNetCore README promise null wherever a nullable value object appears. Cause. ASP.NET adds oneOf [null, $ref] around a component only when the schema carries x-is-nullable-property, set by ShouldApplyNullablePropertySchema, which needs a PropertyInfo whose NullabilityInfoContext WriteState is Nullable: not a get-only property (Unknown), a field (a FieldInfo) or an oblivious reference type. The inlined wrapped type takes its null from JsonPropertyInfo.IsGetNullable || IsSetNullable, true in all three. ValueObjectSchemaTransformer left the null to ASP.NET wherever the value object is a component. Change. Where the value object is not inlined, has a component and the property's contract says it is nullable, the transformer marks the schema with x-is-nullable-property, so ASP.NET writes the null-or as it does for a settable annotated property. That reads ASP.NET's internal key (OpenApiConstants.NullableProperty, Microsoft.AspNetCore.OpenApi 10.0.12) the way decision 106 reads x-schema-id. It never takes a mark away. The analyst's candidate also excluded a value object over object; that is unreachable, since the generators refuse IValue (CMTK1005), so it is left out. By the maintainer's decision the rule holds under every OpenAPI version: under 2.0 the property becomes ASP.NET's documented form, allOf [{x-nullable: true}] without the reference (KB-026, the loss the README states for a nullable value-object property), as a settable annotated one already was. Tests. ComponentNullablePropertyTests: the three positions under 3.1, 3.0 and 2.0, each against the settable annotated property of the same value object beside it (ASP.NET's own form) and against the twin (TimeSpan? for ProbeDuration?, string for ProbeLabel): 9 cases. Reverted, all 9 fail with the bare $ref where the null-or (3.x) or the 2.0 null flag was expected. PolymorphicBaseTests' fixture gains a get-only nullable value object, which the base's own branch now writes as the derived branch does (it passes either way; coverage, not a guard). Variants (decision 95). 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). There is no catalogue for it, so the test holds one: 31 members, each a value object's beside its wrapped type's, under 3.1, 3.0 and 2.0, with value objects as components and inlined, and RespectNullableAnnotations off and on (372 cases). Struct value object: settable, get-only, computed, field, readonly field, init, required init, positional record, a [JsonInclude] private setter, a get-only property of a struct, and non-nullable settable, get-only and field. Class value object: annotated settable, get-only, field, required init and positional; oblivious settable, get-only and field; [AllowNull], [MaybeNull], [DisallowNull], [NotNull]; typed by a type parameter as settable, get-only, field and required init; non-nullable settable and get-only. The type-parameter members and the RespectNullableAnnotations axis follow the catalogue of a500025, an unpushed fix of the same defect made in another session. Reverted, 93 of the class's 381 cases fail: the 9 above and 14 members in every component row. Besides the three named, they are siblings of the same cause, fixed by the same mark: a computed property, a readonly field, a get-only property of a struct, [MaybeNull], [DisallowNull], and a type parameter's get-only property and field. Already right, and unchanged: settable, init, required init, positional, the private setter, [AllowNull], [NotNull], a type parameter's settable and required-init property, every non-nullable member, and every inlined row. The shape.* catalogue has no value for a get-only property, an oblivious one or a nullability attribute; they are held in the test, not added to .claude/probe-axes.md, since the axes file is in every probe's identity (decision 108). AspNetCore README and docs/aspnet-core.md: no sentence changes; the README already says a nullable property is the component or null, and loses its type under 2.0. Defect class SDL, silent does-less: the document builds and every value object is described, and the null is simply missing, with no error and no setting involved. Two of the three positions need no option at all (a get-only property, an oblivious one), so the cause is not a framework configuration (FCI), and no more specific class of decision 94's table fits. Defect-Class: SDL Found-By: PR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ValueObjectSchemaTransformer.cs | 26 +- .../ComponentNullablePropertyTests.cs | 315 ++++++++++++++++++ .../NullablePositionTests.cs | 2 +- .../PolymorphicBaseTests.cs | 5 + 5 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentNullablePropertyTests.cs 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(); } From ed94ae6a817be8d11ea6df7e4bcfcad281ce3ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 18 Oct 2026 08:56:53 +0200 Subject: [PATCH 2/3] test: C21 cites the OpenAPI 2.0 loss of a nullable component by the row's axes With the fix before this commit, a nullable value object that is a component is OpenAPI 2.0's allOf [{x-nullable: true}] without the reference wherever the serializer's contract says the property is nullable. HttpBaseline.SchemaParity cited the not-promised entry openapi-2-nullable-component-loses-its-type only where side (1), ASP.NET alone, writes the value object's position the same, and ASP.NET alone adds that null only for a settable annotated property. Rows 178 (a record class over DateTime, generic container) and 194 (a record class over TimeSpan, nullable-property in a record class) then failed: the twin's property keeps its type beside x-nullable, the value object's has the null flag alone. The check now cites the same entry by the row's axes as well: OpenAPI 2.0, a nullable value object, not inlined (CreateSchemaReferenceId other than null-inlines-all, the entry's own axes), where the value object's property is the null flag without its reference or type and the twin's property admits null. prober try --probe C21OpenapiSchemaParityProbe, with the rows file of release/2.2.0: release/2.2.0 (938d8f8): expected-refusal 129, inapplicable 4, inconclusive 6, pass 155. With the fix alone: violation 2 (rows 178, 194), pass 154, inapplicable 3. With this commit: expected-refusal 129, inapplicable 5, inconclusive 6, pass 154, exit 0; row 181 (a validated class over Uri) went from inapplicable to pass, and 178 and 194 are the cited loss. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/harness/HttpBaseline.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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. From 89b310abca9091f2fb29a2a42a36a67d5806fa6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 18 Oct 2026 09:04:43 +0200 Subject: [PATCH 3/3] test: C21's nullable seed applies over the component mark The fix's new lines in ValueObjectSchemaTransformer moved the context of the seed for nullable-admits-null-as-the-wrapped-does, and `prober seeds --probe C21OpenapiSchemaParityProbe` reported it as not applying (patch failed at line 98). The patch is made again against the new code, with the same defect: a nullable value object's position is Property wherever it is Nullable or its property is nullable, and None as a parameter, which puts the null where ASP.NET's exporter does not. Co-Authored-By: Claude Opus 5.5 --- ...-nullable-admits-null-as-the-wrapped-does.patch | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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.