Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
### FSharp.Data.JsonSchema.OpenApi 3.1.0

* Fix `InvalidOperationException: The input schema must be an OpenApiSchema or OpenApiSchemaReference` thrown by `Microsoft.AspNetCore.OpenApi` 10.0.3+ for any endpoint whose request/response type is (or contains) an F# discriminated union or a self-recursive type (#30)
* `FSharpSchemaTransformer` now registers component schemas into the live `OpenApiDocument` on net10 instead of leaving them unregistered with dangling references
* Fix self-recursive types (e.g. a tree or linked-list shaped DU) all resolving their self-reference to the same hardcoded component id on net10; each now gets its own correctly-named component
* On net10, definition/component ids (DU cases and nested referenced types alike) are now qualified by their owning root type's name (e.g. `TreeNode.Leaf` instead of `Leaf`), so two different types that happen to share a case or type name no longer silently collide in `components/schemas`
* Pin the net10 `Microsoft.OpenApi` dependency to 2.9.0 (was transitively 2.0.0), above the version affected by GHSA-v5pm-xwqc-g5wc

### FSharp.Data.JsonSchema.NJsonSchema 3.1.0

* No changes (version bump for consistency)

### FSharp.Data.JsonSchema.Core 3.1.0

* No changes (version bump for consistency)

### FSharp.Data.JsonSchema.NJsonSchema 3.0.1

* Fix recursive type serialization for self-referential types with nullable fields
Expand Down
824 changes: 824 additions & 0 deletions docs/superpowers/plans/2026-07-25-openapi-dangling-schema-ref-fix.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
# Design: Fix dangling OpenApiSchemaReference on net10 / Microsoft.OpenApi 2.x (#30)

**Created**: 2026-07-25
**Status**: Draft
**Input**: GitHub issue [#30](https://github.com/fsprojects/FSharp.Data.JsonSchema/issues/30) — "OpenApi: The input schema must be an OpenApiSchema or OpenApiSchemaReference"

## Problem

`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.

## Root Cause

`OpenApiSchemaTranslator.mkRefSchema` (the net10 / `Microsoft.OpenApi` 2.x
code path) builds every schema reference as:

```fsharp
let private mkRefSchema (typeId: string) : OpenApiSchema =
let s = mkSchema ()
s.AnyOf.Add(OpenApiSchemaReference(typeId, null))
s
```

The `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:

```fsharp
// 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`:

```csharp
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.

### Secondary defect: self-ref component id collision

Self-recursive refs (`SchemaNode.Ref "#"`, produced by
`SchemaAnalyzer.getOrAnalyzeRef` for a type that recursively contains
itself) are translated via:

```fsharp
| SchemaNode.Ref typeId ->
if typeId = "#" then
mkRefSchema (rootSchema.Title |> Option.ofObj |> Option.defaultValue "root")
else
mkRefSchema typeId
```

`rootSchema.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.

### Contributing factor: untested version range

`FSharp.Data.JsonSchema.OpenApi.fsproj`'s net10 target references:

```xml
<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.

## Fix

Scope: `NET10_0_OR_GREATER` code paths only, in
`FSharp.Data.JsonSchema.OpenApi`. `net9.0` is unaffected and out of scope.

### `OpenApiSchemaTranslator.fs`

1. `translate` gains an optional parameter:

```fsharp
let translate (doc: SchemaDocument) (?rootTypeId: string) : OpenApiSchema * Map<string, OpenApiSchema> =
let rootTypeId = defaultArg rootTypeId "root"
...
```

`rootTypeId` replaces the dead `rootSchema.Title` lookup 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.

2. New function, net10-only:

```fsharp
let registerComponents
(document: OpenApiDocument)
(rootSchema: OpenApiSchema)
(componentSchemas: Map<string, OpenApiSchema>) : unit
```

Behavior:
- Registers every entry of `componentSchemas` into the document via
`document.AddComponent(id, schema)` (public API on `OpenApiDocument`,
idempotent — uses `TryAdd` internally).
- Recursively walks `rootSchema` and each registered component schema's
`AnyOf`, `OneOf`, `AllOf`, `Properties`, `Items`, and
`AdditionalProperties`. Every `OpenApiSchemaReference` found (all of
which are null-hosted, since `mkRefSchema` is the only place that
constructs one) is replaced with a freshly constructed
`OpenApiSchemaReference(id, document)` bound to the real document, so
`.Target` resolves correctly.
- No cycle-detection is needed: the schema object graph produced by
`translate` is 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.

### `FSharpSchemaTransformer.fs`

- Compute `rootTypeId = config.TypeIdResolver ty` (the same resolver
`SchemaAnalyzer` already uses internally) and pass it to `translate`.
- After `copySchemaInto translatedRoot schema`, if `context.Document` is
non-null, call
`OpenApiSchemaTranslator.registerComponents context.Document schema componentSchemas`.
`context.Document` is 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.

### Test project (`FSharp.Data.JsonSchema.OpenApi.Tests`)

- Change the net10 `Microsoft.AspNetCore.OpenApi` package reference from
the floating `10.0.0-*` to a pinned, current version (`10.0.10` at 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`,
register `FSharpSchemaTransformer`, call `MapOpenApi`, 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/schemas` contains an entry
per case and the operation's response schema's `anyOf` refs 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.

## Out of Scope

- `net9.0` / `Microsoft.OpenApi.Models` path: not broken, not touched.
- `FSharp.Data.JsonSchema.Core` and `FSharp.Data.JsonSchema` (NJsonSchema)
packages: unaffected, no changes.
- General overhaul of `SchemaGeneratorConfig.TypeIdResolver` or DU case id
generation: existing behavior (`case.Name` for case-level definitions) is
unchanged; this fix only supplies a previously-missing id for the
self-ref (root) case.

## Versioning

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).

## Testing Plan

1. Unit: `OpenApiSchemaTranslator.translate` with an explicit `rootTypeId`
produces the expected self-ref id instead of `"root"`.
2. Unit: `registerComponents` registers all component schemas into a test
`OpenApiDocument` and rewrites all dangling refs to resolve via
`.Target`.
3. Integration (new): end-to-end `WebApplication` + `MapOpenApi` +
`/openapi/v1.json` for a plain DU and for `TreeNode`, run against the
pinned current `Microsoft.AspNetCore.OpenApi` version, asserting the
request succeeds and the document is structurally correct.
4. Full existing suite (573 tests across Core/main/OpenApi per current
baseline) must remain green.
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>3.0.1</VersionPrefix>
<VersionPrefix>3.1.0</VersionPrefix>
<Version>$(Version)</Version>
<Owners>Ryan Riley</Owners>
<Authors>Ryan Riley</Authors>
Expand Down
108 changes: 58 additions & 50 deletions src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
schema.Type <- schemaType

interface ISchemaProcessor with
member this.Process(context) = this.Process(context)

Check warning on line 85 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.

Check warning on line 85 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.

[<Obsolete("No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.")>]
type SingleCaseDuSchemaProcessor() =
Expand All @@ -102,7 +102,7 @@
schema.EnumerationNames.Add(case.Name)

interface ISchemaProcessor with
member this.Process(context) = this.Process(context)

Check warning on line 105 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.

Check warning on line 105 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.

[<Obsolete("No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.")>]
type MultiCaseDuSchemaProcessor(?casePropertyName) =
Expand Down Expand Up @@ -201,14 +201,14 @@
s

// Attach each case definition.
let name = Dictionary.getUniqueKey schema.Definitions case.Name

Check warning on line 204 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.

Check warning on line 204 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.
// printfn "Adding case %s to dict: %A" name schema.Definitions
schema.Definitions.Add(name, caseSchema)
// Add each schema to the anyOf collection.
schema.AnyOf.Add(JsonSchema(Reference = caseSchema))

interface ISchemaProcessor with
member this.Process(context) = this.Process(context)

Check warning on line 211 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.


[<Obsolete("No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.")>]
Expand All @@ -230,7 +230,7 @@
property.IsRequired <- true

interface ISchemaProcessor with
member this.Process(context) = this.Process(context)

Check warning on line 233 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This construct is deprecated. No longer used internally. Use FSharp.Data.JsonSchema.Core.SchemaAnalyzer instead.



Expand All @@ -254,6 +254,13 @@
static let cache =
Collections.Concurrent.ConcurrentDictionary<(string * Core.UnionEncodingStyle) * Type, JsonSchema>()

// Namotion.Reflection (used by SchemaNameGenerator.Generate below, and internally by
// NJsonSchema's own base generator) keeps global, non-thread-safe type-metadata caches.
// Concurrent schema generation for different types — e.g. under a parallel test runner —
// can corrupt them and throw "_type is not initialized" from CachedType.get_Type(). Since
// we don't control that dependency's internals, serialize our own entry point into it.
static let generationLock = obj ()

static member internal CreateInternal(?casePropertyName, ?unionEncoding) =
let casePropertyName' = defaultArg casePropertyName FSharp.Data.Json.DefaultCasePropertyName
let nameGen = SchemaNameGenerator()
Expand Down Expand Up @@ -284,56 +291,57 @@
typeByName

fun (ty: Type) ->
let doc = Core.SchemaAnalyzer.analyze config ty
let schema = NJsonSchemaTranslator.translate doc
// Set title using the same logic as the old SchemaNameGenerator
// Don't set title for bare option/voption types (they produce empty schemas)
match doc.Root with
| Core.SchemaNode.Any -> ()
| _ ->
let title = nameGen.Generate(ty)
if not (System.String.IsNullOrEmpty title) then
schema.Title <- title
// Add empty description for .NET enums (matching NJsonSchema behavior)
if Reflection.isIntegerEnum ty then
schema.Description <- ""
// Set additionalProperties = false for fieldless DU enums
if FSharpType.IsUnion(ty) && Reflection.allCasesEmpty ty then
schema.AllowAdditionalProperties <- false
// Apply post-processing to definitions based on their F# types
let typeMap = collectTypeMap ty
for kv in schema.Definitions do
match typeMap.TryGetValue(kv.Key) with
| true, defTy ->
if Reflection.isIntegerEnum defTy then
kv.Value.Description <- ""
elif FSharpType.IsUnion(defTy, true) && Reflection.allCasesEmpty defTy then
kv.Value.AllowAdditionalProperties <- false
| _ -> ()
// Apply DataAnnotation attributes from record fields
let applyAnnotations (recordTy: Type) (targetSchema: JsonSchema) =
if FSharpType.IsRecord(recordTy, true) then
for field in FSharpType.GetRecordFields(recordTy, true) do
let propName = config.PropertyNamingPolicy field.Name
match targetSchema.Properties.TryGetValue(propName) with
| true, prop ->
for attr in field.GetCustomAttributes(true) do
match attr with
| :? System.ComponentModel.DataAnnotations.RequiredAttribute ->
prop.MinLength <- 1
| :? System.ComponentModel.DataAnnotations.MaxLengthAttribute as ml ->
prop.MaxLength <- Nullable ml.Length
| :? System.ComponentModel.DataAnnotations.RangeAttribute as r ->
prop.Minimum <- Nullable (Convert.ToDecimal(r.Minimum :> obj))
prop.Maximum <- Nullable (Convert.ToDecimal(r.Maximum :> obj))
| _ -> ()
| _ -> ()
applyAnnotations ty schema
for kv in schema.Definitions do
match typeMap.TryGetValue(kv.Key) with
| true, defTy -> applyAnnotations defTy kv.Value
| _ -> ()
schema
lock generationLock (fun () ->
let doc = Core.SchemaAnalyzer.analyze config ty
let schema = NJsonSchemaTranslator.translate doc
// Set title using the same logic as the old SchemaNameGenerator
// Don't set title for bare option/voption types (they produce empty schemas)
match doc.Root with
| Core.SchemaNode.Any -> ()
| _ ->
let title = nameGen.Generate(ty)
if not (System.String.IsNullOrEmpty title) then
schema.Title <- title
// Add empty description for .NET enums (matching NJsonSchema behavior)
if Reflection.isIntegerEnum ty then
schema.Description <- ""
// Set additionalProperties = false for fieldless DU enums
if FSharpType.IsUnion(ty) && Reflection.allCasesEmpty ty then
schema.AllowAdditionalProperties <- false
// Apply post-processing to definitions based on their F# types
let typeMap = collectTypeMap ty
for kv in schema.Definitions do
match typeMap.TryGetValue(kv.Key) with
| true, defTy ->
if Reflection.isIntegerEnum defTy then
kv.Value.Description <- ""
elif FSharpType.IsUnion(defTy, true) && Reflection.allCasesEmpty defTy then
kv.Value.AllowAdditionalProperties <- false
| _ -> ()
// Apply DataAnnotation attributes from record fields
let applyAnnotations (recordTy: Type) (targetSchema: JsonSchema) =
if FSharpType.IsRecord(recordTy, true) then
for field in FSharpType.GetRecordFields(recordTy, true) do
let propName = config.PropertyNamingPolicy field.Name
match targetSchema.Properties.TryGetValue(propName) with
| true, prop ->
for attr in field.GetCustomAttributes(true) do
match attr with
| :? System.ComponentModel.DataAnnotations.RequiredAttribute ->
prop.MinLength <- 1
| :? System.ComponentModel.DataAnnotations.MaxLengthAttribute as ml ->
prop.MaxLength <- Nullable ml.Length
| :? System.ComponentModel.DataAnnotations.RangeAttribute as r ->
prop.Minimum <- Nullable (Convert.ToDecimal(r.Minimum :> obj))

Check warning on line 335 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This upcast is unnecessary - the types are identical
prop.Maximum <- Nullable (Convert.ToDecimal(r.Maximum :> obj))

Check warning on line 336 in src/FSharp.Data.JsonSchema.NJsonSchema/JsonSchema.fs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

This upcast is unnecessary - the types are identical
| _ -> ()
| _ -> ()
applyAnnotations ty schema
for kv in schema.Definitions do
match typeMap.TryGetValue(kv.Key) with
| true, defTy -> applyAnnotations defTy kv.Value
| _ -> ()
schema)

/// Creates a generator using the specified casePropertyName and unionEncoding.
static member Create(?casePropertyName, ?unionEncoding) =
Expand Down
Loading
Loading