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
48 changes: 40 additions & 8 deletions Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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