Skip to content

Commit a55d787

Browse files
panesofglassclaude
andauthored
fix: inline format-annotated primitives instead of dangling component refs (#29) (#34)
DateTime, DateTimeOffset, DateOnly, TimeOnly, Guid, Uri, and TimeSpan fields were routed through SchemaAnalyzer's Ref/definitions path, producing a needless $ref to a single-purpose component (e.g. "WeatherForecast.DateTime") instead of an inline {"type": "string", "format": "date-time"} schema. Scoped the fix to the OpenApi package via a new OpenApiSchemaTranslator.inlineFormatOnlyDefinitions pass, run before translation, rather than changing Core's SchemaAnalyzer — that would have altered the main NJsonSchema package's byte-identical snapshot output for the same types, which is intentional, tested behavior there. Claude-Session: https://claude.ai/code/session_01Bs5FwyUrCG4dpj3JygXu3J Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 48a24e8 commit a55d787

5 files changed

Lines changed: 147 additions & 1 deletion

File tree

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* 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
66
* 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`
77
* Pin the net10 `Microsoft.OpenApi` dependency to 2.9.0 (was transitively 2.0.0), above the version affected by GHSA-v5pm-xwqc-g5wc
8+
* Fix format-annotated types (`DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `Guid`, `Uri`, `TimeSpan`) generating a needless `$ref` to a single-purpose component (e.g. `WeatherForecast.DateTime`) instead of an inline `{"type": "string", "format": "date-time"}` schema (#29)
89

910
### FSharp.Data.JsonSchema.NJsonSchema 3.1.0
1011

src/FSharp.Data.JsonSchema.OpenApi/FSharpSchemaTransformer.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ type FSharpSchemaTransformer(config: SchemaGeneratorConfig) =
8080
member _.TransformAsync(schema, context, _cancellationToken) =
8181
let ty = context.JsonTypeInfo.Type
8282
if isFSharpType ty then
83-
let doc = SchemaAnalyzer.analyze config ty
83+
let doc = SchemaAnalyzer.analyze config ty |> OpenApiSchemaTranslator.inlineFormatOnlyDefinitions
8484
#if NET10_0_OR_GREATER
8585
let rootTypeId = config.TypeIdResolver ty
8686
let (translatedRoot, _componentSchemas) =

src/FSharp.Data.JsonSchema.OpenApi/OpenApiSchemaTranslator.fs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,50 @@ module OpenApiSchemaTranslator =
105105
schema
106106
#endif
107107

108+
// ── Format-only definition inlining ──
109+
110+
let rec private inlineNode (inlineable: Map<string, SchemaNode>) (node: SchemaNode) : SchemaNode =
111+
match node with
112+
| SchemaNode.Ref typeId ->
113+
match Map.tryFind typeId inlineable with
114+
| Some prim -> prim
115+
| None -> node
116+
| SchemaNode.Object obj ->
117+
SchemaNode.Object
118+
{ obj with
119+
Properties = obj.Properties |> List.map (fun p -> { p with Schema = inlineNode inlineable p.Schema }) }
120+
| SchemaNode.Array items -> SchemaNode.Array(inlineNode inlineable items)
121+
| SchemaNode.AnyOf schemas -> SchemaNode.AnyOf(schemas |> List.map (inlineNode inlineable))
122+
| SchemaNode.OneOf(schemas, discriminator) -> SchemaNode.OneOf(schemas |> List.map (inlineNode inlineable), discriminator)
123+
| SchemaNode.Nullable inner -> SchemaNode.Nullable(inlineNode inlineable inner)
124+
| SchemaNode.Map valueSchema -> SchemaNode.Map(inlineNode inlineable valueSchema)
125+
| SchemaNode.Primitive _
126+
| SchemaNode.Enum _
127+
| SchemaNode.Const _
128+
| SchemaNode.Any -> node
129+
130+
/// A definition that's just a bare format-annotated primitive (as produced for
131+
/// DateTime, Guid, Uri, TimeSpan, etc.) doesn't need its own component schema —
132+
/// a $ref to it is a needless indirection that shows up as an extra, oddly-named
133+
/// component in the OpenAPI document (see #29). Inline every reference to such a
134+
/// definition directly and drop the now-unreferenced definition.
135+
let inlineFormatOnlyDefinitions (doc: SchemaDocument) : SchemaDocument =
136+
let inlineable =
137+
doc.Definitions
138+
|> List.choose (fun (key, value) ->
139+
match value with
140+
| SchemaNode.Primitive _ -> Some(key, value)
141+
| _ -> None)
142+
|> Map.ofList
143+
if Map.isEmpty inlineable then
144+
doc
145+
else
146+
{ Root = inlineNode inlineable doc.Root
147+
Definitions =
148+
doc.Definitions
149+
|> List.filter (fun (key, _) -> not (Map.containsKey key inlineable))
150+
|> List.map (fun (key, value) -> key, inlineNode inlineable value) }
151+
108152
// ── Core translation ──
109153

110154
/// Shared translation implementation. `rootTypeId` names the component a self-ref

test/FSharp.Data.JsonSchema.OpenApi.Tests/EndToEndTests.fs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,47 @@ let private hasSchema (root: JsonElement) (name: string) : bool =
7979
| false, _ -> false
8080
| true, schemas -> schemas.TryGetProperty name |> fst
8181

82+
/// Matches the exact repro from GitHub issue #29.
83+
type WeatherForecast =
84+
{ Date: System.DateTime
85+
TemperatureC: int
86+
shape: Shape
87+
Summary: string option }
88+
8289
[<Tests>]
8390
let endToEndTests =
8491
testList "endToEnd" [
92+
test "OpenAPI document generation inlines DateTime fields instead of a dangling $ref" {
93+
let app =
94+
startApp (fun app ->
95+
app.MapGet(
96+
"/weather",
97+
System.Func<WeatherForecast>(fun () ->
98+
{ Date = System.DateTime.Now; TemperatureC = 1; shape = Point; Summary = None })
99+
)
100+
|> ignore
101+
)
102+
try
103+
let (status, body) = getOpenApiDocument app
104+
Expect.equal status System.Net.HttpStatusCode.OK "200 OK"
105+
use jsonDoc = JsonDocument.Parse body
106+
let root = jsonDoc.RootElement
107+
#if NET10_0_OR_GREATER
108+
Expect.isFalse (hasSchema root "WeatherForecast.DateTime") "DateTime must not be registered as its own component"
109+
#endif
110+
let dateSchema =
111+
root
112+
.GetProperty("components")
113+
.GetProperty("schemas")
114+
.GetProperty("WeatherForecast")
115+
.GetProperty("properties")
116+
.GetProperty("date")
117+
Expect.equal (dateSchema.GetProperty("type").GetString()) "string" "date field inlines as a string"
118+
Expect.equal (dateSchema.GetProperty("format").GetString()) "date-time" "date field keeps the date-time format"
119+
Expect.isFalse (dateSchema.TryGetProperty("$ref") |> fst) "date field must not be a $ref"
120+
finally
121+
stopApp app
122+
}
85123
test "OpenAPI document generation succeeds for a discriminated union response" {
86124
let app = startApp (fun app -> app.MapGet("/shape", System.Func<Shape>(fun () -> Point)) |> ignore)
87125
try

test/FSharp.Data.JsonSchema.OpenApi.Tests/TranslatorTests.fs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,69 @@ let definitionsTests =
224224
}
225225
]
226226

227+
[<Tests>]
228+
let inlineFormatOnlyDefinitionsTests =
229+
testList "translator/inlineFormatOnlyDefinitions" [
230+
test "a Ref to a bare Primitive definition is inlined and the definition dropped" {
231+
let doc = {
232+
Root =
233+
SchemaNode.Object {
234+
Properties = [ { Name = "date"; Schema = SchemaNode.Ref "DateTime"; Description = None } ]
235+
Required = [ "date" ]
236+
AdditionalProperties = false
237+
TypeId = None
238+
Description = None
239+
Title = None
240+
}
241+
Definitions = [ "DateTime", SchemaNode.Primitive(PrimitiveType.String, Some "date-time") ]
242+
}
243+
let inlined = OpenApiSchemaTranslator.inlineFormatOnlyDefinitions doc
244+
Expect.isEmpty inlined.Definitions "no definitions remain"
245+
match inlined.Root with
246+
| SchemaNode.Object obj ->
247+
match obj.Properties.[0].Schema with
248+
| SchemaNode.Primitive(PrimitiveType.String, Some "date-time") -> ()
249+
| other -> failtestf "expected inlined Primitive, got %A" other
250+
| other -> failtestf "expected Object root, got %A" other
251+
}
252+
253+
test "a Ref to a non-Primitive definition (e.g. a record) is left as a Ref" {
254+
let doc = {
255+
Root = SchemaNode.Ref "Widget"
256+
Definitions = [
257+
"Widget",
258+
SchemaNode.Object {
259+
Properties = []
260+
Required = []
261+
AdditionalProperties = false
262+
TypeId = None
263+
Description = None
264+
Title = None
265+
}
266+
]
267+
}
268+
let inlined = OpenApiSchemaTranslator.inlineFormatOnlyDefinitions doc
269+
Expect.equal inlined.Definitions doc.Definitions "definition preserved"
270+
Expect.equal inlined.Root doc.Root "root Ref preserved"
271+
}
272+
273+
test "translating an inlined document produces no dangling component" {
274+
let doc = {
275+
Root = SchemaNode.Object {
276+
Properties = [ { Name = "id"; Schema = SchemaNode.Ref "Guid"; Description = None } ]
277+
Required = [ "id" ]
278+
AdditionalProperties = false
279+
TypeId = None
280+
Description = None
281+
Title = None
282+
}
283+
Definitions = [ "Guid", SchemaNode.Primitive(PrimitiveType.String, Some "guid") ]
284+
}
285+
let (_, components) = translate (OpenApiSchemaTranslator.inlineFormatOnlyDefinitions doc)
286+
Expect.isTrue components.IsEmpty "no component schema registered for the inlined primitive"
287+
}
288+
]
289+
227290
#if NET10_0_OR_GREATER
228291
[<Tests>]
229292
let documentBindingTests =

0 commit comments

Comments
 (0)