From 336f2a3341a5eb04baedae0b94c10f111c6ce916 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 11 Jul 2026 01:38:23 +0300 Subject: [PATCH] feat: externally dereference $dynamicRef Delegate the .dynamicReference case of externallyDereferenced to the wrapped JSONReference's external deref -- the same path $ref uses. External $dynamicRef targets are fetched via the loader and rewritten to internal component references; internal dynamic refs are unchanged. The result stays a .dynamicReference (serializes as $dynamicRef, preserving the dynamic semantic). Part of #359. --- .../DereferencedJSONSchema.swift | 12 +++---- .../JSONSchemaDynamicReferenceTests.swift | 33 +++++++++++++++++++ .../migration_guides/v7_migration_guide.md | 20 ++++++----- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index d98a36ca2..02f8986b8 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -675,12 +675,12 @@ extension JSONSchema: ExternallyDereferenceable { newSchema = .init( schema: .reference(newReference, core) ) - case .dynamicReference: - // TODO: external dereferencing of `$dynamicRef` is not implemented; - // deferred alongside local dynamic-scope resolution (see #359). - newComponents = .noComponents - newSchema = self - newMessages = [] + case .dynamicReference(let dynamicRef, let core): + // Delegate to the wrapped JSONReference's external deref (same path as $ref). + let (newReference, components, messages) = try await dynamicRef.jsonReference.externallyDereferenced(with: loader) + newComponents = components + newMessages = messages + newSchema = .init(schema: .dynamicReference(JSONDynamicReference(newReference), core)) case .fragment(_): newComponents = .noComponents newSchema = self diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift index 2454ce807..36f1cd131 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -208,3 +208,36 @@ final class JSONSchemaDynamicReferenceTests: XCTestCase { } } } + +#if ExternalLoading +extension JSONSchemaDynamicReferenceTests { + func test_externalDeref_dynamicReference_external() async throws { + // An external `$dynamicRef` is dereferenced through its underlying + // `JSONReference` -- same path as `$ref`: fetch + convert to an + // internal component reference. + let schema = JSONSchema.dynamicReference( + JSONDynamicReference(.external(.init(string: "./schema.json")!)) + ) + + let (newSchema, components, messages) = try await schema.externallyDereferenced(with: JSONReferenceTests.SchemaLoader.self) + + XCTAssertTrue(newSchema.isDynamicReference) + XCTAssertEqual(newSchema.dynamicReference?.name, "__schema_json") + XCTAssertEqual(components, .init(schemas: ["__schema_json": .string])) + XCTAssertEqual(messages, ["./schema.json"]) + } + + func test_externalDeref_dynamicReference_internal_noop() async throws { + // An internal `$dynamicRef` (anchor) is not external; external + // dereferencing leaves it unchanged. + let schema = JSONSchema.dynamicReference(.anchor("node")) + + let (newSchema, components, messages) = try await schema.externallyDereferenced(with: JSONReferenceTests.SchemaLoader.self) + + XCTAssertTrue(newSchema.isDynamicReference) + XCTAssertEqual(newSchema.dynamicReference?.absoluteString, "#node") + XCTAssertTrue(components.schemas.isEmpty) + XCTAssertEqual(messages, []) + } +} +#endif diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md index a10bebb54..9acaee25d 100644 --- a/documentation/migration_guides/v7_migration_guide.md +++ b/documentation/migration_guides/v7_migration_guide.md @@ -17,14 +17,18 @@ 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. +### Dereferencing `$dynamicRef` + +`locallyDereferenced()` and `JSONSchema.dereferenced(in:)` **throw** on a +`$dynamicRef` — a `DereferencedJSONSchema` must not contain references, and +dynamic-scope resolution is tracked in #359. The raw `JSONSchema` AST still +carries `.dynamicReference` for tools that read schemas without dereferencing. + +External dereferencing (`externallyDereferenced(with:)`, under the +`ExternalLoading` trait) resolves an external `$dynamicRef` the same way it +resolves an external `$ref`: fetch via the `ExternalLoader` and rewrite to an +internal component. The result stays `.dynamicReference` (still serializes as +`$dynamicRef`); cross-document dynamic-scope resolution is out of scope. ### `$ref` with a plain fragment now round-trips verbatim