Created: 2026-07-25 Status: Draft Input: GitHub issue #30 — "OpenApi: The input schema must be an OpenApiSchema or OpenApiSchemaReference"
FSharp.Data.JsonSchema.OpenApi's FSharpSchemaTransformer throws when
Microsoft.AspNetCore.OpenApi 10.0.3+ generates an OpenAPI document for any
endpoint whose request/response type is (or contains) an F# discriminated
union, or any self-recursive record/DU:
System.InvalidOperationException: The input schema must be an OpenApiSchema or OpenApiSchemaReference.
at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.UnwrapOpenApiSchema(IOpenApiSchema sourceSchema)
at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.ResolveReferenceForSchema(...)
Confirmed via local repro: building a minimal ASP.NET Core app targeting
net10.0 with Microsoft.AspNetCore.OpenApi 10.0.10, registering
FSharpSchemaTransformer, and exposing an endpoint returning a 3-case DU
reproduces the exact exception and stack shape reported in the issue and its
comments.
OpenApiSchemaTranslator.mkRefSchema (the net10 / Microsoft.OpenApi 2.x
code path) builds every schema reference as:
let private mkRefSchema (typeId: string) : OpenApiSchema =
let s = mkSchema ()
s.AnyOf.Add(OpenApiSchemaReference(typeId, null))
sThe null is the reference's host OpenApiDocument. Separately,
FSharpSchemaTransformer.TransformAsync calls OpenApiSchemaTranslator.translate,
copies the translated root schema into the schema object ASP.NET provides,
and discards the componentSchemas map that translate also returns —
the existing code comment says as much:
// Register component schemas
// The transformer context doesn't expose document components directly,
// so we attach definitions as nested anyOf references.
// In a real integration, the document transformer or middleware
// would register these in components/schemas.Neither the null host document nor the missing registration crashed under
Microsoft.AspNetCore.OpenApi 10.0.0, because that version's document
builder didn't walk and resolve AnyOf/OneOf reference trees as strictly
after transformers ran. Starting at 10.0.3, OpenApiSchemaService's
ResolveReferenceForSchema recursively walks the whole schema tree
(including AnyOf) and calls UnwrapOpenApiSchema on every node found.
UnwrapOpenApiSchema requires its input to be either a concrete
OpenApiSchema or an OpenApiSchemaReference whose .Target resolves to a
concrete OpenApiSchema. Tracing into Microsoft.OpenApi 2.0.0's
BaseOpenApiReferenceHolder<T, U, V>.Target:
public virtual U? Target
{
get
{
if (Reference.HostDocument is null) return default;
return Reference.HostDocument.ResolveReferenceTo<U>(Reference, this as IOpenApiSchema);
}
}Because every OpenApiSchemaReference we construct has HostDocument = null,
.Target always resolves to null, so UnwrapOpenApiSchema always falls
through to its throw branch for these references — exactly the reported
exception.
This is not a new defect introduced by ASP.NET 10.0.3 — the references were
always dangling and the component schemas were always unregistered. The
newer Microsoft.AspNetCore.OpenApi release simply started walking and
resolving the tree strictly enough to notice.
net9.0 (targeting Microsoft.OpenApi.Models / the pre-2.0 API surface) is
unaffected: OpenApiSchemaTransformerContext on net9 has no Document
property at all, and that generation's OpenApiReference is plain metadata
on the schema object that doesn't require host-document resolution to
serialize. The bug and its fix are confined to the NET10_0_OR_GREATER code
path.
Self-recursive refs (SchemaNode.Ref "#", produced by
SchemaAnalyzer.getOrAnalyzeRef for a type that recursively contains
itself) are translated via:
| SchemaNode.Ref typeId ->
if typeId = "#" then
mkRefSchema (rootSchema.Title |> Option.ofObj |> Option.defaultValue "root")
else
mkRefSchema typeIdrootSchema.Title is never set anywhere in OpenApiSchemaTranslator.fs or
FSharpSchemaTransformer.fs, so this always evaluates to the literal string
"root". This is harmless today only because the resulting reference is
already dangling and never resolved. Once component registration is fixed
(below), every self-recursive type used in the same OpenApiDocument (e.g.
TreeNode and LinkedNode both appearing across different endpoints) would
register under the same component id "root" and silently resolve to
whichever type registered first — a correctness regression traded for the
crash fix, unless corrected in the same change.
FSharp.Data.JsonSchema.OpenApi.fsproj's net10 target references:
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-*" />This is a prerelease-only floating pattern. In this repo's restore it
resolves to exactly 10.0.0 — the one version that predates the breaking
strictness change. The package has never been built or tested against
10.0.3+, which is why this shipped and went unnoticed through a full release
cycle.
Scope: NET10_0_OR_GREATER code paths only, in
FSharp.Data.JsonSchema.OpenApi. net9.0 is unaffected and out of scope.
-
translategains an optional parameter:let translate (doc: SchemaDocument) (?rootTypeId: string) : OpenApiSchema * Map<string, OpenApiSchema> = let rootTypeId = defaultArg rootTypeId "root" ...
rootTypeIdreplaces the deadrootSchema.Titlelookup as the id used for self-refs (Ref "#"). Existing call sites (translate doc) keep compiling unchanged; the default value only matters when no root type name is supplied, preserving current unit-test behavior. -
New function, net10-only:
let registerComponents (document: OpenApiDocument) (rootSchema: OpenApiSchema) (componentSchemas: Map<string, OpenApiSchema>) : unit
Behavior:
- Registers every entry of
componentSchemasinto the document viadocument.AddComponent(id, schema)(public API onOpenApiDocument, idempotent — usesTryAddinternally). - Recursively walks
rootSchemaand each registered component schema'sAnyOf,OneOf,AllOf,Properties,Items, andAdditionalProperties. EveryOpenApiSchemaReferencefound (all of which are null-hosted, sincemkRefSchemais the only place that constructs one) is replaced with a freshly constructedOpenApiSchemaReference(id, document)bound to the real document, so.Targetresolves correctly. - No cycle-detection is needed: the schema object graph produced by
translateis acyclic by construction — a reference is always a leaf node (OpenApiSchemaReference), never an inlined copy of the type it points to, so the walk terminates on its own.
- Registers every entry of
- Compute
rootTypeId = config.TypeIdResolver ty(the same resolverSchemaAnalyzeralready uses internally) and pass it totranslate. - After
copySchemaInto translatedRoot schema, ifcontext.Documentis non-null, callOpenApiSchemaTranslator.registerComponents context.Document schema componentSchemas.context.Documentis only ever null when ASP.NET invokes schema generation outside the real document-build flow (an internal-only edge case); that path keeps today's behavior and is out of scope.
- Change the net10
Microsoft.AspNetCore.OpenApipackage reference from the floating10.0.0-*to a pinned, current version (10.0.10at time of writing) so the test suite actually exercises the code path that broke. - Add a real end-to-end integration test: build a minimal
WebApplication, registerFSharpSchemaTransformer, callMapOpenApi, issue an in-process HTTP request for/openapi/v1.json, and assert:- The response is 200 with a well-formed OpenAPI document.
- For a 3-case DU (e.g.
Shape),components/schemascontains an entry per case and the operation's response schema'sanyOfrefs resolve to them. - For a self-recursive DU (
TreeNode), the document builds without throwing and the recursive case's ref resolves to a schema registered under a stable, unique id (not the literal"root").
This directly exercises the exact code path that broke (ASP.NET's
OpenApiDocumentService walking the real OpenApiDocument), which the
existing tests — unit tests against OpenApiSchemaTranslator.translate in
isolation — do not.
net9.0/Microsoft.OpenApi.Modelspath: not broken, not touched.FSharp.Data.JsonSchema.CoreandFSharp.Data.JsonSchema(NJsonSchema) packages: unaffected, no changes.- General overhaul of
SchemaGeneratorConfig.TypeIdResolveror DU case id generation: existing behavior (case.Namefor case-level definitions) is unchanged; this fix only supplies a previously-missing id for the self-ref (root) case.
Minor version bump for FSharp.Data.JsonSchema.OpenApi — this is a bug fix,
but it changes the transformer's runtime behavior (it now mutates the live
OpenApiDocument by registering components, which it never did before).
- Unit:
OpenApiSchemaTranslator.translatewith an explicitrootTypeIdproduces the expected self-ref id instead of"root". - Unit:
registerComponentsregisters all component schemas into a testOpenApiDocumentand rewrites all dangling refs to resolve via.Target. - Integration (new): end-to-end
WebApplication+MapOpenApi+/openapi/v1.jsonfor a plain DU and forTreeNode, run against the pinned currentMicrosoft.AspNetCore.OpenApiversion, asserting the request succeeds and the document is structurally correct. - Full existing suite (573 tests across Core/main/OpenApi per current baseline) must remain green.