Keep nullability on nullable strong-type wrappers in Swashbuckle OpenAPI#109
Merged
Conversation
Swashbuckle renders a wrapper-typed property (`NonEmptyString?`, `Positive<int>?`, …) as a bare `$ref` to the wrapper component, which in OpenAPI 3.0 cannot carry the member''s nullability. The inliner then collapsed the ref to the wrapper''s flat wire shape and dropped the nullable marker, so codegen produced a non-null type — making `NonEmptyString?` less precise than a plain `string?`. Detect per-property nullability in the Swashbuckle PropertyAnnotation filter and record it on the use-site wrapper; the shared inliner now carries the null bit onto the merged wire shape. Result: 3.0 emits `nullable: true`, 3.1 emits `"null"` in the `type` union. The Microsoft pipeline already handled this via its oneOf+nullable form and is unchanged. Strengthen the shared OpenAPI shape contract: nullable scalar and array wrapper tests now assert the null marker is actually present (across Swashbuckle 3.0, Microsoft 3.0, and Microsoft 3.1) instead of tolerating its absence. Fixes #108 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CoverageLines: 3837 / 5188 (74.0%) Branches: 2057 / 2754 (74.7%) Files changed in this PR
StrongTypes — lines 93.1% (1520/1632), branches 88.2% (997/1130)Booleans — lines 100.0% (14/14), branches 84.6% (22/26)
Collections — lines 90.2% (276/306), branches 85.1% (114/134)
Digits — lines 97.7% (85/87), branches 93.8% (30/32)
Emails — lines 98.6% (68/69), branches 92.5% (37/40)
Enums — lines 100.0% (58/58), branches 95.5% (21/22)
Exceptions — lines 78.9% (15/19), branches 50.0% (7/14)
Maybe — lines 92.1% (151/164), branches 83.9% (94/112)
Nullables — lines 100.0% (12/12), branches 83.3% (20/24)
Numbers — lines 100.0% (84/84), branches 94.4% (17/18)
Result — lines 98.5% (333/338), branches 95.6% (417/436)
Strings — lines 84.7% (133/157), branches 84.4% (54/64)
generated — lines 89.8% (291/324), branches 78.8% (164/208)
StrongTypes.Analyzers — lines 94.7% (322/340), branches 86.8% (132/152)(root) — lines 94.7% (322/340), branches 86.8% (132/152)
StrongTypes.Api — lines 96.7% (327/338), branches 86.3% (88/102)(root) — lines 100.0% (11/11), branches n/a (0/0)
Controllers — lines 96.9% (221/228), branches 86.3% (88/102)
Data — lines 100.0% (54/54), branches n/a (0/0)
Entities — lines 75.0% (12/16), branches n/a (0/0)
Models — lines 100.0% (29/29), branches n/a (0/0)
StrongTypes.AspNetCore — lines 89.3% (151/169), branches 84.5% (71/84)(root) — lines 89.3% (151/169), branches 84.5% (71/84)
StrongTypes.AspNetCore.TestApi — lines 90.7% (39/43), branches 92.5% (37/40)(root) — lines 100.0% (6/6), branches n/a (0/0)
Controllers — lines 89.2% (33/37), branches 92.5% (37/40)
StrongTypes.EfCore — lines 89.3% (117/131), branches 71.4% (40/56)(root) — lines 89.3% (117/131), branches 71.4% (40/56)
StrongTypes.FsCheck — lines 77.4% (96/124), branches n/a (0/0)(root) — lines 77.4% (96/124), branches n/a (0/0)
StrongTypes.OpenApi.Core — lines 91.2% (427/468), branches 83.2% (288/346)(root) — lines 91.8% (256/279), branches 86.3% (164/190)
Inlining — lines 90.5% (171/189), branches 79.5% (124/156)
StrongTypes.OpenApi.Microsoft — lines 39.1% (425/1088), branches 40.3% (171/424)(root) — lines 94.6% (245/259), branches 74.6% (97/130)
Binding — lines 98.1% (103/105), branches 78.6% (55/70)
Collections — lines 100.0% (19/19), branches 100.0% (8/8)
Digits — lines 100.0% (11/11), branches 100.0% (2/2)
Emails — lines 100.0% (11/11), branches 100.0% (2/2)
Inlining — lines 100.0% (7/7), branches 50.0% (1/2)
Maybe — lines 100.0% (12/12), branches 100.0% (2/2)
Numbers — lines 100.0% (8/8), branches 100.0% (2/2)
Strings — lines 100.0% (9/9), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 0.0% (0/647), branches 0.0% (0/204)
StrongTypes.OpenApi.Swashbuckle — lines 94.1% (239/254), branches 78.3% (166/212)(root) — lines 98.7% (76/77), branches 93.4% (71/76)
Binding — lines 88.3% (106/120), branches 64.3% (72/112)
Collections — lines 100.0% (6/6), branches 100.0% (4/4)
Digits — lines 100.0% (10/10), branches 100.0% (4/4)
Emails — lines 100.0% (10/10), branches 100.0% (4/4)
Inlining — lines 100.0% (2/2), branches n/a (0/0)
Maybe — lines 100.0% (13/13), branches 75.0% (3/4)
Numbers — lines 100.0% (8/8), branches 100.0% (4/4)
Strings — lines 100.0% (8/8), branches 100.0% (4/4)
StrongTypes.OpenApi.TestApi.Microsoft — lines 41.0% (163/398), branches 31.6% (65/206)(root) — lines 100.0% (14/14), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 38.8% (149/384), branches 30.9% (63/204)
StrongTypes.OpenApi.TestApi.Shared — lines 0.0% (0/192), branches n/a (0/0)(root) — lines 0.0% (0/192), branches n/a (0/0)
StrongTypes.OpenApi.TestApi.Swashbuckle — lines 100.0% (11/11), branches 100.0% (2/2)(root) — lines 100.0% (11/11), branches 100.0% (2/2)
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #108.
Problem
Swashbuckle renders a wrapper-typed property (
NonEmptyString?,Positive<int>?,Digit?,Email?, ...) as a bare$refto the wrapper component. In OpenAPI 3.0 a bare$refcan''t carry the member''s nullability, and the inliner then collapsed the ref to the wrapper''s flat wire shape — dropping the null marker entirely. So aNonEmptyString?response field came out as non-nullable, andopenapi-typescriptdropped the| null, making the strong type less precise than a plainstring?.Analysis — who was affected
I dumped the emitted document for all three pipelines (Swashbuckle 3.0, Microsoft 3.0, Microsoft 3.1) for every nullable wrapper:
T?)NonEmptyString?,Email?,Positive<T>?,NonNegative<T>?,Negative<T>?,NonPositive<T>?,Digit?oneOf+nullable:true)oneOfwith"null"branch)NonEmptyEnumerable<T>?$ref)So Microsoft was already correct on both 3.0 and 3.1 — the framework wraps the member in its own
oneOf+nullable form, which our transformer doesn''t disturb. The bug was Swashbuckle-only, and it hit every scalar wrapper (the ones rendered as components/$ref). Array wrappers were already fine because Swashbuckle inlines arrays and sets the marker there.Fix
PropertyAnnotationSchemaFilter(Swashbuckle) now detects per-property nullability (value-typeNullable<T>and nullable reference types viaNullabilityInfoContext) and records the null bit on the use-site wrapper — even when the member carries no validation annotations.StrongTypeInliner(shared Core) carries that null bit onto the merged wire shape when it inlines the wrapper.SchemaPaint.IsNullable/MarkNullablehelpers operate on theJsonSchemaType.Nullbit, so the OpenAPI serializer emits the version-correct form automatically:nullable: true(3.0) or"null"in thetypeunion (3.1).The inliner change is gated on the use-site carrying the null bit, which the Microsoft pipeline never sets — so Microsoft output is unchanged (verified).
Maybe<T>?is intentionally out of scope:Maybe<T>already encodes absence and isn''t rendered through this path.Tests
Strengthened the shared OpenAPI shape contract (run against all three pipelines):
AssertNullableAndUnwrap, which fails if a nullable property has no null marker (the previous helper tolerated its absence), then returns the bare wire shape for a strict deep-compare. It accepts every shape the pipelines use: 3.0 flat (nullable:true), 3.0oneOf+nullable:true, 3.1oneOfwith a"null"branch, and 3.1typearrays.NonEmptyString/Positive<int>/Digit/Email/NonEmptyEnumerabletests now assert nullability is actually present (and the inner shape is exact), across Swashbuckle 3.0, Microsoft 3.0, and Microsoft 3.1.All 294 OpenAPI integration tests pass; full solution builds clean.
Also updated
Skill/references/openapi.mdto document that nullable wrappers keep their nullability on both pipelines.Generated with Claude Code