From dc6d041d53739a091fc850d9684ad6e4f54b74fc Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Thu, 30 Jul 2026 00:02:01 +0300 Subject: [PATCH] fix(deref): resolve component-form $dynamicRef Part of #359. externallyDereferenced() rewrites external $dynamicRef targets into internal component references. Local dereferencing previously rejected that component form, so the loaded target could not be inlined. Resolve component/path-form $dynamicRef values through the same path as $ref. Plain-name anchors retain the existing dynamic-scope behavior. --- .../DereferencedJSONSchema.swift | 48 ++++++++++++++--- .../JSONSchemaDynamicReferenceTests.swift | 53 ++++++++++++++++++- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index eee4443c7..575dbc9a8 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -537,9 +537,12 @@ extension JSONSchema: LocallyDereferenceable { return dereferenced case .dynamicReference(let reference, let context): - // Only `#anchor` dynamic refs bind to the dynamic scope; component/path/external - // forms have no scope entry and intentionally throw below. - if case .internal(.anchor(let anchorName)) = reference.jsonReference, + let jsonRef = reference.jsonReference + + // Dynamic-scope resolution for plain-name anchors (the + // generics/recursive pattern): the outermost in-scope + // `$dynamicAnchor` wins (JSON Schema 2020-12 §7.7). + if case .internal(.anchor(let anchorName)) = jsonRef, let target = dynamicScope[anchorName] { let cycleKey = AnyHashable("dynamicRef:#\(anchorName)") if references.contains(cycleKey) { @@ -567,11 +570,40 @@ extension JSONSchema: LocallyDereferenceable { return dereferenced } - throw GenericError( - subjectName: "JSONSchema", - details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): no matching `$dynamicAnchor` found in dynamic scope.", - codingPath: [] - ) + + // Plain anchor with no matching `$dynamicAnchor` in scope: OpenAPIKit + // has no plain-`$anchor` index, so this cannot be resolved. + if case .internal(.anchor(let anchorName)) = jsonRef { + throw GenericError( + subjectName: "JSONSchema", + details: "Cannot dereference `$dynamicRef` ('#\(anchorName)'): no matching `$dynamicAnchor` found in dynamic scope.", + codingPath: [] + ) + } + + // Component/path-form `$dynamicRef`: the fragment is a JSON Pointer + // (no plain name), so dynamic-scope matching does not apply and the + // reference behaves like `$ref` (§7.7). This also covers external + // `$dynamicRef` targets that `externallyDereferenced()` rewrote to + // internal component references after loading. + var dereferenced = try jsonRef._dereferenced(in: components, following: references) { resolved, refs, resolvedName in + try resolved._dereferenced(in: components, following: refs, dereferencedFromComponentNamed: resolvedName, 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 case .boolean(let context): return .boolean(addComponentNameExtension(to: context)) case .object(let coreContext, let objectContext): diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift index e7231b43b..7b807db4e 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -335,8 +335,8 @@ final class JSONSchemaDynamicReferenceTests: XCTestCase { } 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. + // A `$dynamicRef` whose target is a component path resolves like `$ref` + // (§7.7). Here `Foo` is not in Components, so dereferencing throws. let jsonString = """ { "type": "object", @@ -351,6 +351,33 @@ final class JSONSchemaDynamicReferenceTests: XCTestCase { XCTAssertThrowsError(try schema.dereferenced(in: .noComponents)) } + func test_dereference_componentFormDynamicRefResolves() throws { + // A component-form `$dynamicRef` (JSON-Pointer fragment, no plain name) + // behaves like `$ref` per §7.7. The target component is inlined. + let components = OpenAPI.Components(schemas: [ + "Foo": .string, + "Holder": .object( + .init(), + .init(properties: [ + "item": .dynamicReference(.init(.internal(.component(name: "Foo")))) + ]) + ) + ]) + + 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 item: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["item"]) + guard case .string = item else { + XCTFail("expected component-form $dynamicRef to inline to .string, got \(item)") + return + } + } + func test_dereference_siblingDynamicRefsResolveIndependently() throws { // Two sibling properties each holding `$dynamicRef "#T"` resolve // independently -- the cycle guard inserted for one must not leak to @@ -470,5 +497,27 @@ extension JSONSchemaDynamicReferenceTests { XCTAssertTrue(components.schemas.isEmpty) XCTAssertEqual(messages, []) } + + func test_externalDeref_thenLocalDeref_inlinesExternalTarget() async throws { + // End-to-end: external `$dynamicRef` → external deref loads the target + // and rewrites to an internal component ref → local deref inlines it + // via the static fallback (§7.7: JSON-Pointer fragment behaves like + // `$ref`). Mirrors the `external-dynamic-ref.yaml` fixture scenario. + let schema = JSONSchema.dynamicReference( + JSONDynamicReference(.external(.init(string: "./schema.json")!)) + ) + + let (rewritten, extComponents, _) = try await schema.externallyDereferenced(with: JSONReferenceTests.SchemaLoader.self) + + // After external deref the dynamic ref points at a loaded component. + XCTAssertEqual(rewritten.dynamicReference?.name, "__schema_json") + + // Local deref inlines the loaded target (`.string`). + let dereferenced = try rewritten.dereferenced(in: extComponents) + guard case .string = dereferenced else { + XCTFail("expected external $dynamicRef to inline to .string, got \(dereferenced)") + return + } + } } #endif