diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index d98a36ca2..951212ff7 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -305,9 +305,10 @@ extension DereferencedJSONSchema { internal init( _ arrayContext: JSONSchema.ArrayContext, resolvingIn components: OpenAPI.Components, - following references: Set + following references: Set, + dynamicScope: [String: JSONSchema] = [:] ) throws { - items = try arrayContext.items.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + items = try arrayContext.items.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } maxItems = arrayContext.maxItems _minItems = arrayContext._minItems _uniqueItems = arrayContext._uniqueItems @@ -401,17 +402,18 @@ extension DereferencedJSONSchema { internal init( _ objectContext: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components, - following references: Set + following references: Set, + dynamicScope: [String: JSONSchema] = [:] ) throws { - properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } - patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } + patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { case .a(let bool): additionalProperties = .a(bool) case .b(let schema): - additionalProperties = .b(try schema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil)) + additionalProperties = .b(try schema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope)) case nil: additionalProperties = nil } @@ -477,6 +479,23 @@ extension JSONSchema: LocallyDereferenceable { in components: OpenAPI.Components, following references: Set, dereferencedFromComponentNamed name: String? + ) throws -> DereferencedJSONSchema { + try _dereferenced(in: components, following: references, dereferencedFromComponentNamed: name, dynamicScope: [:]) + } + + /// Scope-aware dereferencing. + /// + /// `dynamicScope` maps a `$dynamicAnchor` name to the **outermost** schema + /// resource bearing that anchor on the current resolution path (first-wins + /// insertion). A `$dynamicRef` is resolved against this scope per JSON + /// Schema 2020-12 dynamic-scope rules: the outermost matching anchor wins. + /// Non-recursive targets are inlined; recursive or unresolvable dynamic + /// references throw (a `DereferencedJSONSchema` must not contain references). + internal func _dereferenced( + in components: OpenAPI.Components, + following references: Set, + dereferencedFromComponentNamed name: String?, + dynamicScope outerDynamicScope: [String: JSONSchema] ) throws -> DereferencedJSONSchema { func addComponentNameExtension(to context: CoreContext) -> CoreContext { var extensions = context.vendorExtensions @@ -486,12 +505,32 @@ extension JSONSchema: LocallyDereferenceable { return context.with(vendorExtensions: extensions) } + // Seed the dynamic scope with this schema's own `$dynamicAnchor` and any + // declared in its `$defs` (the JSON Schema "generics" pattern). First-wins + // keeps the outermost resource's anchor, per the dynamic-scope rules. + var dynamicScope = outerDynamicScope + if let anchor = self.dynamicAnchor, dynamicScope[anchor] == nil { + dynamicScope[anchor] = self + } + for (_, def) in self.defs { + if let defAnchor = def.dynamicAnchor, dynamicScope[defAnchor] == nil { + dynamicScope[defAnchor] = def + } + } + switch value { case .null(let coreContext): return .null(addComponentNameExtension(to: coreContext)) case .reference(let reference, let context): - var dereferenced = try reference - ._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) + // Thread the dynamic scope across the `$ref` boundary (outermost-wins). + var newReferences = references + let (inserted, _) = newReferences.insert(reference) + guard inserted else { + throw OpenAPI.Components.ReferenceCycleError(ref: reference.absoluteString) + } + var dereferenced = try components + .lookup(reference) + ._dereferenced(in: components, following: newReferences, dereferencedFromComponentNamed: reference.name, dynamicScope: dynamicScope) if !context.required { dereferenced = dereferenced.optionalSchemaObject() @@ -508,14 +547,41 @@ extension JSONSchema: LocallyDereferenceable { dereferenced = dereferenced.with(vendorExtensions: extensions) return dereferenced - case .dynamicReference(let reference, _): - // A `DereferencedJSONSchema` must not contain references. Dynamic-scope - // resolution is not yet implemented (see #359), so a `$dynamicRef` - // cannot be inlined; dereferencing fails rather than retaining the - // reference and breaking the `Dereferenced...` invariant. + case .dynamicReference(let reference, let context): + // Resolve `$dynamicRef` against the dynamic scope. Only plain anchor + // references participate; component/path/external dynamic refs have no + // matching dynamic anchor and throw. + if case .internal(.anchor(let anchorName)) = reference.jsonReference, + let target = dynamicScope[anchorName] { + let cycleKey = AnyHashable("dynamicRef:#\(anchorName)") + if references.contains(cycleKey) { + // Recursive dynamic reference: cannot inline without retaining a + // reference, so fail -- consistent with static `$ref` cycles. + throw OpenAPI.Components.ReferenceCycleError(ref: reference.absoluteString) + } + var newReferences = references + newReferences.insert(cycleKey) + var dereferenced = try target + ._dereferenced(in: components, following: newReferences, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) + + if !context.required { + dereferenced = dereferenced.optionalSchemaObject() + } + if let refDescription = context.description { + dereferenced = dereferenced.with(description: refDescription) + } + + var extensions = dereferenced.vendorExtensions + if let name { + extensions[OpenAPI.Components.componentNameExtension] = .init(name) + } + dereferenced = dereferenced.with(vendorExtensions: extensions) + + return dereferenced + } throw GenericError( subjectName: "JSONSchema", - details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): dynamic references are not resolved by local dereferencing.", + details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): no matching `$dynamicAnchor` found in dynamic scope.", codingPath: [] ) case .boolean(let context): @@ -523,12 +589,12 @@ extension JSONSchema: LocallyDereferenceable { case .object(let coreContext, let objectContext): return try .object( addComponentNameExtension(to: coreContext), - DereferencedJSONSchema.ObjectContext(objectContext, resolvingIn: components, following: references) + DereferencedJSONSchema.ObjectContext(objectContext, resolvingIn: components, following: references, dynamicScope: dynamicScope) ) case .array(let coreContext, let arrayContext): return try .array( addComponentNameExtension(to: coreContext), - DereferencedJSONSchema.ArrayContext(arrayContext, resolvingIn: components, following: references) + DereferencedJSONSchema.ArrayContext(arrayContext, resolvingIn: components, following: references, dynamicScope: dynamicScope) ) case .number(let coreContext, let numberContext): return .number(addComponentNameExtension(to: coreContext), numberContext) @@ -537,16 +603,16 @@ extension JSONSchema: LocallyDereferenceable { case .string(let coreContext, let stringContext): return .string(addComponentNameExtension(to: coreContext), stringContext) case .all(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .all(of: schemas, core: addComponentNameExtension(to: coreContext)) case .one(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .one(of: schemas, core: addComponentNameExtension(to: coreContext)) case .any(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .any(of: schemas, core: addComponentNameExtension(to: coreContext)) case .not(let jsonSchema, core: let coreContext): - return .not(try jsonSchema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil), core: addComponentNameExtension(to: coreContext)) + return .not(try jsonSchema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope), core: addComponentNameExtension(to: coreContext)) case .fragment(let context): return .fragment(addComponentNameExtension(to: context)) } diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift index 2454ce807..e0d1631da 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -184,18 +184,76 @@ final class JSONSchemaDynamicReferenceTests: XCTestCase { XCTAssertTrue(schema.isDynamicReference) } - // MARK: - Dereferencing + // MARK: - Dereferencing (dynamic-scope resolution) - func test_dereference_throwsOnDynamicReference() throws { - // A `DereferencedJSONSchema` must not contain references. Until - // dynamic-scope resolution lands (follow-up to #359), a `$dynamicRef` - // cannot be inlined, so local dereferencing fails rather than - // retaining the reference. + func test_dereference_genericsInline() throws { + // The JSON Schema "generics" pattern: a `$dynamicAnchor` lives in `$defs` + // and the `$dynamicRef` target is a leaf (non-recursive) schema. + // Dereferencing inlines the concrete target. let jsonString = """ { + "$defs": { + "itemType": { + "$dynamicAnchor": "T", + "type": "string" + }, + "other": { "type": "number" } + }, "type": "object", "properties": { - "item": { "$dynamicRef": "#category" } + "items": { + "type": "array", + "items": { "$dynamicRef": "#T" } + } + } + } + """ + + let box = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + let dereferenced = try box.dereferenced(in: .noComponents) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let items = try XCTUnwrap(objectContext.properties["items"]) + let itemsItems: DereferencedJSONSchema = try XCTUnwrap(items.arrayContext?.items) + + // The leaf `$defs.itemType` (`string`) was inlined through the dynamic ref. + guard case .string = itemsItems else { + XCTFail("expected dynamic ref to inline to .string, got \(itemsItems)") + return + } + } + + func test_dereference_recursiveThrows() throws { + // A self-referential schema: `Node` declares `$dynamicAnchor: node` and + // its `next` points back at `#node`. Inlining would not terminate, so + // dereferencing must throw (consistent with recursive static `$ref`). + let jsonString = """ + { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": { "type": "string" }, + "next": { "$dynamicRef": "#node" } + } + } + """ + + let node = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + + XCTAssertThrowsError(try node.dereferenced(in: .noComponents)) + } + + func test_dereference_unresolvableThrows() throws { + // A `$dynamicRef` whose anchor is not declared anywhere in scope cannot + // be resolved and must throw (rather than degrade to `any`/`unknown`). + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#unmatched" } } } """ @@ -207,4 +265,177 @@ final class JSONSchemaDynamicReferenceTests: XCTestCase { XCTAssertTrue(description.contains("$dynamicRef"), "expected error to mention `$dynamicRef`, got: \(description)") } } + + func test_dereference_scopePropagatesAcrossRefBoundary() throws { + // `Outer` references `Inner`. The dynamic anchor "leaf" lives in `Outer`'s + // `$defs`; `Inner` contains the `$dynamicRef`. The dynamic scope must + // travel across the `$ref` boundary so Inner's `$dynamicRef` resolves to + // Outer's concrete leaf type (outermost anchor wins). + let components = OpenAPI.Components( + schemas: [ + "Outer": .reference( + .component(named: "Inner"), + .init( + defs: [ + "L": .boolean(.init(dynamicAnchor: "leaf")) + ] + ) + ), + "Inner": .object( + .init(), + .init(properties: [ + "flag": .dynamicReference(.anchor("leaf")) + ]) + ) + ] + ) + + let outer = try XCTUnwrap(components.schemas["Outer"]) + let dereferenced = try outer.dereferenced(in: components) + + // Outer is a reference to Inner, so after dereferencing we see Inner's + // object shape with `flag` resolved through the dynamic scope. + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let flag: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["flag"]) + + // The dynamic ref resolved to Outer's `$defs.L` (.boolean) across the $ref. + guard case .boolean = flag else { + XCTFail("expected flag to resolve to Outer's `$defs.L` (.boolean) across the $ref, got \(flag)") + return + } + } + + func test_dereference_optionalDescribedDynamicRef() throws { + // A `$dynamicRef` that is optional (required: false) and carries a + // description: both propagate to the inlined result, mirroring `$ref`. + let item = JSONSchema.dynamicReference(.anchor("T"), required: false, description: "the item") + let box = JSONSchema.object( + .init(defs: ["T": .string(.init(dynamicAnchor: "T"), .init())]), + .init(properties: ["item": item]) + ) + + let dereferenced = try box.dereferenced(in: .noComponents) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let resolved = try XCTUnwrap(objectContext.properties["item"]) + + // Inlined to the leaf `.string`, with optionality and description preserved. + guard case .string = resolved else { + XCTFail("expected dynamic ref to inline to .string, got \(resolved)") + return + } + XCTAssertFalse(resolved.required) + XCTAssertEqual(resolved.description, "the item") + } + + func test_dereference_nonAnchorDynamicRefThrows() throws { + // A `$dynamicRef` whose target is a component path (not a plain anchor) + // is not resolved via a dynamic anchor and throws. + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#/components/schemas/Foo" } + } + } + """ + + let schema = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + + XCTAssertThrowsError(try schema.dereferenced(in: .noComponents)) + } + + func test_dereference_siblingDynamicRefsResolveIndependently() throws { + // Two sibling properties each holding `$dynamicRef "#T"` resolve + // independently -- the cycle guard inserted for one must not leak to + // the other (both inline to the same leaf type). + let jsonString = """ + { + "$defs": { "T": { "$dynamicAnchor": "T", "type": "string" } }, + "type": "object", + "properties": { + "a": { "$dynamicRef": "#T" }, + "b": { "$dynamicRef": "#T" } + } + } + """ + + let schema = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + let dereferenced = try schema.dereferenced(in: .noComponents) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + for key in ["a", "b"] { + let resolved: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties[key]) + guard case .string = resolved else { + XCTFail("expected sibling `\(key)` to inline to .string, got \(resolved)") + return + } + } + } + + func test_dereference_optionalReferenceProperty() throws { + // Covers the `.reference` optional (required: false) path threaded by + // the scope-aware dereferencer: an optional `$ref` property dereferences + // to an optional result. + let components = OpenAPI.Components(schemas: [ + "Foo": .string, + "Holder": .object( + .init(), + .init(properties: [ + "opt": .reference(.component(named: "Foo"), .init(required: false)) + ]) + ) + ]) + + let holder = try XCTUnwrap(components.schemas["Holder"]) + let dereferenced = try holder.dereferenced(in: components) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let opt: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["opt"]) + XCTAssertFalse(opt.required) + } + + func test_dereference_dynamicRefReachedViaRef() throws { + // A `$ref` to a component that is itself a `$dynamicRef`: the component + // name propagates (dereferencedFromComponentNamed) and the dynamicRef + // resolves against the referencing schema's dynamic scope. + let components = OpenAPI.Components( + schemas: [ + "Wrapper": .object( + .init(defs: ["T": .string(.init(dynamicAnchor: "T"), .init())]), + .init(properties: [ + "item": .reference(.component(named: "DynRef")) + ]) + ), + "DynRef": .dynamicReference(.anchor("T")) + ] + ) + + let wrapper = try XCTUnwrap(components.schemas["Wrapper"]) + let dereferenced = try wrapper.dereferenced(in: components) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let item: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["item"]) + + // $ref DynRef -> DynRef is $dynamicRef #T -> resolves to Wrapper's $defs.T (.string). + guard case .string = item else { + XCTFail("expected item to resolve to .string via $ref -> $dynamicRef, got \(item)") + return + } + } } diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md index a10bebb54..e30195a21 100644 --- a/documentation/migration_guides/v7_migration_guide.md +++ b/documentation/migration_guides/v7_migration_guide.md @@ -17,14 +17,14 @@ encodes/decodes the `$dynamicRef` keyword. Schemas whose only attribute is `$dynamicRef` now decode as `.dynamicReference` instead of decoding as an empty `.fragment` with an "unsupported attributes" warning. -### Local dereferencing fails on `$dynamicRef` - -A `DereferencedJSONSchema` must not contain references. Until dynamic-scope -resolution is added (tracked in #359), `locallyDereferenced()` and -`JSONSchema.dereferenced(in:)` **throw** when they encounter a `$dynamicRef` -they cannot inline, mirroring how unresolvable static `$ref` values fail. The -raw `JSONSchema` AST still carries `.dynamicReference` for tools that read -schemas without dereferencing. +### Local dereferencing resolves `$dynamicRef` against the dynamic scope + +`locallyDereferenced()` and `JSONSchema.dereferenced(in:)` now resolve +`$dynamicRef` against the dynamic scope (the outermost in-scope `$dynamicAnchor` +wins, per JSON Schema 2020-12). Non-recursive targets are inlined. Recursive +or unresolvable `$dynamicRef`s **throw** — a `DereferencedJSONSchema` must not +contain references, so a dynamic ref that cannot be fully inlined fails the +same way a recursive static `$ref` does (`ReferenceCycleError`). ### `$ref` with a plain fragment now round-trips verbatim