From 506c0758dcd476502ef217630f454204b5adf278 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Tue, 7 Jul 2026 12:54:43 +0300 Subject: [PATCH 1/2] feat: parse and preserve $dynamicRef (JSON Schema 2020-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parsing and round-trip serialization for the JSON Schema 2020-12 $dynamicRef / $dynamicAnchor keywords (spec §7.7), adopted by OpenAPI 3.1.x as its schema dialect. $dynamicAnchor parsing landed in #360, but $dynamicRef was previously dropped: schemas whose only attribute was $dynamicRef decoded as empty fragments with a 'Found nothing but unsupported attributes' warning. With this change the keyword is parsed and preserved on the JSONSchema AST, unblocking downstream tooling (e.g. apple/swift-openapi-generator#547) to observe it. Scope (parse/preserve only): - New JSONDynamicReference type wrapping JSONReference with $dynamicRef encode/decode. - New .anchor(String) case on JSONReference.InternalReference so plain fragment references like '#category' parse and round-trip. - New .dynamicReference case on JSONSchema.Schema, threaded through every exhaustive switch and the schema transformations (factory, encode/decode, optional/required/nullable, with(...), fragment combining, external dereferencing). - Decoder recognizes $dynamicRef before the unsupported-attributes fallthrough. - DereferencedJSONSchema retains no reference cases: local dereferencing throws on .dynamicReference (dynamic-scope resolution is a follow-up, tracked in #359). The raw JSONSchema AST still carries .dynamicReference for tools that read schemas without dereferencing. This is a source-breaking change (new public enum cases). The v7 migration guide is added documenting it. Part of #359. --- Sources/OpenAPIKit/JSONDynamicReference.swift | 107 +++++++++ Sources/OpenAPIKit/JSONReference.swift | 12 +- .../DereferencedJSONSchema.swift | 14 ++ .../Schema Object/JSONSchema+Combining.swift | 6 +- .../OpenAPIKit/Schema Object/JSONSchema.swift | 103 ++++++++- .../JSONSchemaDynamicReferenceTests.swift | 210 ++++++++++++++++++ .../migration_guides/v7_migration_guide.md | 40 +++- 7 files changed, 482 insertions(+), 10 deletions(-) create mode 100644 Sources/OpenAPIKit/JSONDynamicReference.swift create mode 100644 Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift diff --git a/Sources/OpenAPIKit/JSONDynamicReference.swift b/Sources/OpenAPIKit/JSONDynamicReference.swift new file mode 100644 index 000000000..36c2618d7 --- /dev/null +++ b/Sources/OpenAPIKit/JSONDynamicReference.swift @@ -0,0 +1,107 @@ +import OpenAPIKitCore + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// A `JSONDynamicReference` represents a JSON Schema `$dynamicRef` +/// (JSON Schema 2020-12, [§7.7](https://json-schema.org/draft/2020-12/json-schema-core#section-7.7)). +/// +/// Like `JSONReference`, a dynamic reference can point either to a component +/// in the Components Object, to another location within the same document +/// (including a `$dynamicAnchor`), or to another file. +/// +/// OpenAPIKit parses and round-trips `$dynamicRef`. Dynamic-scope *evaluation* +/// is a runtime concern belonging to JSON Schema validators; local +/// dereferencing (`locallyDereferenced()`) does not resolve `$dynamicRef` and +/// fails if it encounters one that cannot be inlined. +@dynamicMemberLookup +public struct JSONDynamicReference: Equatable, Hashable, Sendable { + public let jsonReference: JSONReference + + public init(_ reference: JSONReference) { + self.jsonReference = reference + } + + public subscript(dynamicMember path: KeyPath, T>) -> T { + return jsonReference[keyPath: path] + } + + /// Reference a `$dynamicAnchor` (or `$anchor`) local to this document. + /// + /// - Important: `anchor` does not contain a leading '#'. + public static func anchor(_ anchor: String) -> Self { + return .init(.internal(.anchor(anchor))) + } +} + +// MARK: - Codable + +extension JSONDynamicReference { + private enum CodingKeys: String, CodingKey { + case dynamicRef = "$dynamicRef" + } +} + +extension JSONDynamicReference: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch jsonReference { + case .internal(let reference): + try container.encode(reference.rawValue, forKey: .dynamicRef) + case .external(let url): + try container.encode(url.absoluteString, forKey: .dynamicRef) + } + } +} + +extension JSONDynamicReference: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let referenceString = try container.decode(String.self, forKey: .dynamicRef) + + guard !referenceString.isEmpty else { + throw DecodingError.dataCorruptedError(forKey: .dynamicRef, in: container, debugDescription: "Expected a reference string, but found an empty string instead.") + } + + if referenceString.first == "#" { + guard let internalReference = JSONReference.InternalReference(rawValue: referenceString) else { + throw GenericError( + subjectName: "JSON Dynamic Reference", + details: "Failed to parse a JSON Dynamic Reference from '\(referenceString)'", + codingPath: container.codingPath + ) + } + self = .init(.internal(internalReference)) + } else { + let externalReference: URL? + #if canImport(FoundationEssentials) + externalReference = URL(string: referenceString, encodingInvalidCharacters: false) + #elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS) + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + externalReference = URL(string: referenceString, encodingInvalidCharacters: false) + } else { + externalReference = URL(string: referenceString) + } + #else + externalReference = URL(string: referenceString) + #endif + guard let externalReference else { + throw GenericError( + subjectName: "JSON Dynamic Reference", + details: "Failed to parse a valid URI for a JSON Dynamic Reference from '\(referenceString)'", + codingPath: container.codingPath + ) + } + self = .init(.external(externalReference)) + } + } +} + +// Conforms for parity with JSONReference; lets downstream code key +// Validations on dynamic references (a `Validation`'s `Subject` must be `Validatable`). +extension JSONDynamicReference: Validatable {} diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index fcf7e0119..dca245162 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -131,6 +131,9 @@ public enum JSONReference: Equatabl case component(name: String) /// The reference refers to some path outside the Components Object. case path(Path) + /// The reference refers to a plain URI fragment identifying a + /// `$dynamicAnchor` or `$anchor` (e.g. `#category`). + case anchor(String) /// Get the name of the referenced object. /// @@ -149,6 +152,8 @@ public enum JSONReference: Equatabl return name case .path(let path): return path.components.last?.stringValue + case .anchor(let name): + return name } } @@ -166,7 +171,10 @@ public enum JSONReference: Equatabl } let fragment = rawValue.dropFirst() guard fragment.starts(with: "/components") else { - self = .path(Path(rawValue: String(fragment))) + // A fragment without a leading '/' is a plain anchor (#category). + self = fragment.first == "/" + ? .path(Path(rawValue: String(fragment))) + : .anchor(String(fragment)) return } guard fragment.starts(with: "/components/\(ReferenceType.openAPIComponentsKey)") else { @@ -192,6 +200,8 @@ public enum JSONReference: Equatabl return "#/components/\(ReferenceType.openAPIComponentsKey)/\(name)" case .path(let path): return "#\(path.rawValue)" + case .anchor(let name): + return "#\(name)" } } } diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 5cf00e253..8c46c06b0 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -508,6 +508,16 @@ 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. + throw GenericError( + subjectName: "JSONSchema", + details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): dynamic references are not resolved by local dereferencing.", + codingPath: [] + ) case .boolean(let context): return .boolean(addComponentNameExtension(to: context)) case .object(let coreContext, let objectContext): @@ -665,6 +675,10 @@ extension JSONSchema: ExternallyDereferenceable { newSchema = .init( schema: .reference(newReference, core) ) + case .dynamicReference: + newComponents = .noComponents + newSchema = self + newMessages = [] case .fragment(_): newComponents = .noComponents newSchema = self diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift index 129888695..acce1a9c2 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift @@ -173,6 +173,8 @@ internal struct FragmentCombiner { self.combinedFragment = .array(try leftCoreContext.combined(with: rightCoreContext), arrayContext) case (.fragment(let leftCoreContext), .object(let rightCoreContext, let objectContext)): self.combinedFragment = .object(try leftCoreContext.combined(with: rightCoreContext), objectContext) + case (.fragment(let leftCoreContext), .dynamicReference(let reference, let rightCoreContext)): + self.combinedFragment = .dynamicReference(reference, try leftCoreContext.combined(with: rightCoreContext)) case (.boolean(let leftCoreContext), .boolean(let rightCoreContext)): self.combinedFragment = .boolean(try leftCoreContext.combined(with: rightCoreContext)) @@ -202,6 +204,8 @@ internal struct FragmentCombiner { case (_, .any), (.any, _), (_, .not), (.not, _), (_, .one), (.one, _): throw JSONSchemaResolutionError(.unsupported(because: "not, any(of:), and one(of:) are not yet supported for schema resolution")) + case (_, .dynamicReference), (.dynamicReference, _): + throw JSONSchemaResolutionError(.unsupported(because: "$dynamicRef is not supported for schema simplification")) case (.boolean, _), (.integer, _), (.number, _), @@ -238,7 +242,7 @@ internal struct FragmentCombiner { let jsonSchema: JSONSchema switch combinedFragment.value { - case .fragment, .reference, .null: + case .fragment, .reference, .dynamicReference, .null: jsonSchema = combinedFragment case .boolean(let coreContext): jsonSchema = .boolean(try coreContext.validatedContext()) diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index 273f4751b..a9ff69a05 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -69,6 +69,9 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { public static func reference(_ reference: JSONReference, _ context: CoreContext) -> Self { .init(schema: .reference(reference, context)) } + public static func dynamicReference(_ reference: JSONDynamicReference, _ context: CoreContext) -> Self { + .init(schema: .dynamicReference(reference, context)) + } /// Schemas without a `type`. public static func fragment(_ core: CoreContext) -> Self { .init(schema: .fragment(core)) @@ -89,6 +92,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { indirect case any(of: [JSONSchema], core: CoreContext) indirect case not(JSONSchema, core: CoreContext) case reference(JSONReference, CoreContext) + case dynamicReference(JSONDynamicReference, CoreContext) /// Schemas without a `type`. case fragment(CoreContext) // This allows for the "{}" case and also fragments of schemas that will later be combined with `all(of:)`. } @@ -110,7 +114,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { return .integer(context.format) case .string(let context, _): return .string(context.format) - case .all, .one, .any, .not, .reference, .fragment: + case .all, .one, .any, .not, .reference, .dynamicReference, .fragment: return nil } } @@ -151,7 +155,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { .any(of: _, core: let context), .not(_, core: let context): return context.format.rawValue - case .reference, .null: + case .reference, .dynamicReference, .null: return nil } } @@ -182,7 +186,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { .any(of: _, core: let context as JSONSchemaContext), .not(_, core: let context as JSONSchemaContext): return context.discriminator - case .reference: + case .reference, .dynamicReference: return nil } } @@ -275,6 +279,8 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings, Sendable { return core.defs case .reference(_, let core): return core.defs + case .dynamicReference(_, let core): + return core.defs case .fragment(let core): return core.defs } @@ -368,16 +374,27 @@ extension JSONSchema { return true } - /// Check if a schema is a `.reference`. + /// Check if a schema is a `.reference` (returns `false` for `.dynamicReference`). public var isReference: Bool { guard case .reference = value else { return false } return true } + /// Check if a schema is a `.dynamicReference`. + public var isDynamicReference: Bool { + guard case .dynamicReference = value else { return false } + return true + } + public var reference: JSONReference? { guard case let .reference(reference, _) = value else { return nil } return reference } + + public var dynamicReference: JSONDynamicReference? { + guard case let .dynamicReference(reference, _) = value else { return nil } + return reference + } } // MARK: - Context Accessors @@ -399,7 +416,8 @@ extension JSONSchema { .one(of: _, core: let context as JSONSchemaContext), .any(of: _, core: let context as JSONSchemaContext), .not(_, core: let context as JSONSchemaContext), - .reference(_, let context as JSONSchemaContext): + .reference(_, let context as JSONSchemaContext), + .dynamicReference(_, let context as JSONSchemaContext): return context } } @@ -540,6 +558,8 @@ extension JSONSchema.Schema { return .not(of, core: core.with(vendorExtensions: vendorExtensions)) case .reference(let context, let coreContext): return .reference(context, coreContext.with(vendorExtensions: vendorExtensions)) + case .dynamicReference(let context, let coreContext): + return .dynamicReference(context, coreContext.with(vendorExtensions: vendorExtensions)) case .fragment(let context): return .fragment(context.with(vendorExtensions: vendorExtensions)) } @@ -571,6 +591,8 @@ extension JSONSchema.Schema { return .not(of, core: core.with(id: id)) case .reference(let context, let coreContext): return .reference(context, coreContext.with(id: id)) + case .dynamicReference(let context, let coreContext): + return .dynamicReference(context, coreContext.with(id: id)) case .fragment(let context): return .fragment(context.with(id: id)) } @@ -642,6 +664,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(reference, context.optionalContext()) ) + case .dynamicReference(let reference, let context): + return .init( + warnings: warnings, + schema: .dynamicReference(reference, context.optionalContext()) + ) case .null(let context): return .init( warnings: warnings, @@ -713,6 +740,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(reference, context.requiredContext()) ) + case .dynamicReference(let reference, let context): + return .init( + warnings: warnings, + schema: .dynamicReference(reference, context.requiredContext()) + ) case .null(let context): return .init( warnings: warnings, @@ -779,7 +811,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.nullableContext()) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -848,6 +880,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(allowedValues: allowedValues)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(allowedValues: allowedValues)) + ) case .null(let core): return .init( warnings: warnings, @@ -919,6 +956,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(defaultValue: defaultValue)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(defaultValue: defaultValue)) + ) case .null(let core): return .init( warnings: warnings, @@ -997,6 +1039,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(examples: examples)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(examples: examples)) + ) case .null(let core): return .init( warnings: warnings, @@ -1063,7 +1110,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.with(discriminator: discriminator)) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -1131,6 +1178,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(ref, referenceContext.with(description: description)) ) + case .dynamicReference(let ref, let referenceContext): + return .init( + warnings: warnings, + schema: .dynamicReference(ref, referenceContext.with(description: description)) + ) case .null(let referenceContext): return .init( warnings: warnings, @@ -1930,6 +1982,34 @@ extension JSONSchema { ) ) } + + /// Construct a dynamic reference schema (`$dynamicRef`). + /// + /// See JSON Schema 2020-12 + /// [§7.7](https://json-schema.org/draft/2020-12/json-schema-core#section-7.7). + public static func dynamicReference( + _ reference: JSONDynamicReference, + required: Bool = true, + title: String? = nil, + description: String? = nil, + anchor: String? = nil, + dynamicAnchor: String? = nil, + defs: OrderedDictionary = [:], + xml: OpenAPI.XML? = nil + ) -> JSONSchema { + return .dynamicReference( + reference, + .init( + required: required, + title: title, + description: description, + anchor: anchor, + dynamicAnchor: dynamicAnchor, + defs: defs, + xml: xml + ) + ) + } } // MARK: - Describable @@ -2025,6 +2105,10 @@ extension JSONSchema: Encodable { try core.encode(to: encoder) try reference.encode(to: encoder) + case .dynamicReference(let reference, let core): + try core.encode(to: encoder) + try reference.encode(to: encoder) + case .fragment(let context): var container = encoder.singleValueContainer() @@ -2062,6 +2146,11 @@ extension JSONSchema: Decodable { self = .init(warnings: coreContext.warnings, schema: .reference(ref, coreContext)) return } + if let dynamicRef = try? JSONDynamicReference(from: decoder) { + let coreContext = try CoreContext(from: decoder) + self = .init(warnings: coreContext.warnings, schema: .dynamicReference(dynamicRef, coreContext)) + return + } let container = try decoder.container(keyedBy: SubschemaCodingKeys.self) diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift new file mode 100644 index 000000000..2454ce807 --- /dev/null +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -0,0 +1,210 @@ +// +// JSONSchemaDynamicReferenceTests.swift +// +// Tests for `$dynamicRef` / `$dynamicAnchor` support (JSON Schema 2020-12, [§7.7] +// https://json-schema.org/draft/2020-12/json-schema-core#section-7.7). +// + +import Foundation +import XCTest +import OpenAPIKit + +final class JSONSchemaDynamicReferenceTests: XCTestCase { + + // MARK: - Decoding + + func test_decodeDynamicReference_anchor() throws { + let data = #""" + { + "$dynamicRef": "#category" + } + """#.data(using: .utf8)! + + let schema = try orderUnstableDecode(JSONSchema.self, from: data) + + XCTAssertTrue(schema.isDynamicReference) + XCTAssertFalse(schema.isReference) + XCTAssertEqual(schema.dynamicReference?.absoluteString, "#category") + // No "unsupported attributes" warning -- this is the core regression + // being fixed (previously `$dynamicRef`-only schemas warned and decoded + // as empty fragments). + XCTAssertTrue(schema.warnings.isEmpty, "expected no warnings, got: \(schema.warnings)") + } + + func test_decodeDynamicReference_component() throws { + let data = #""" + { + "$dynamicRef": "#/components/schemas/Foo" + } + """#.data(using: .utf8)! + + let schema = try orderUnstableDecode(JSONSchema.self, from: data) + + XCTAssertTrue(schema.isDynamicReference) + XCTAssertEqual(schema.dynamicReference?.name, "Foo") + XCTAssertTrue(schema.warnings.isEmpty) + } + + func test_decodeDynamicRef_doesNotEmitUnsupportedAttributesWarning() throws { + // Previously a `$dynamicRef` whose only attribute was the dynamic + // reference decoded as an empty fragment with the warning + // "Found nothing but unsupported attributes." + let data = "{\"$dynamicRef\":\"#node\"}".data(using: .utf8)! + + let schema = try orderUnstableDecode(JSONSchema.self, from: data) + + XCTAssertTrue(schema.isDynamicReference) + XCTAssertEqual(schema.dynamicReference?.absoluteString, "#node") + let hasUnsupportedWarning = schema.warnings.contains { warning in + String(describing: warning).contains("unsupported attributes") + } + XCTAssertFalse(hasUnsupportedWarning) + } + + // MARK: - Encoding / round-trip + + func test_encodeDynamicReference_anchor() throws { + let schema = JSONSchema.dynamicReference(.anchor("category")) + + let encoded = try orderUnstableEncode(schema) + + XCTAssertEqual( + try orderUnstableDecode(JSONSchema.self, from: encoded), + schema + ) + let encodedString = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssertTrue(encodedString.contains("$dynamicRef")) + XCTAssertTrue(encodedString.contains("#category")) + } + + func test_refWithPlainFragmentRoundTripsAsAnchor() throws { + // A `$ref` whose fragment has no leading '/' (e.g. "#foo") is a plain + // anchor reference. It must round-trip verbatim rather than being + // rewritten with a slash. + let data = "{\"$ref\":\"#foo\"}".data(using: .utf8)! + + let schema = try orderUnstableDecode(JSONSchema.self, from: data) + XCTAssertTrue(schema.isReference) + XCTAssertEqual(schema.reference?.absoluteString, "#foo") + + let encoded = try orderUnstableEncode(schema) + let encodedString = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssertTrue(encodedString.contains("$ref")) + XCTAssertTrue(encodedString.contains("#foo")) + XCTAssertFalse(encodedString.contains("#/foo")) + } + + func test_dynamicReference_roundTripThroughDocument() throws { + // A realistic recursive schema: BaseCategory is extended by + // LocalizedCategory via `allOf` + `$dynamicAnchor`. Children point + // back at the active category through `$dynamicRef`. + let jsonString = """ + { + "openapi": "3.1.0", + "info": { "title": "test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "BaseCategory": { + "$dynamicAnchor": "category", + "type": "object", + "properties": { + "name": { "type": "string" }, + "children": { + "type": "array", + "items": { "$dynamicRef": "#category" } + } + } + }, + "LocalizedCategory": { + "$dynamicAnchor": "category", + "allOf": [ + { "$ref": "#/components/schemas/BaseCategory" }, + { + "type": "object", + "properties": { + "displayName": { "type": "string" }, + "locale": { "type": "string" } + } + } + ] + } + } + } + } + """ + + let doc = try orderUnstableDecode(OpenAPI.Document.self, from: jsonString.data(using: .utf8)!) + + // The `$dynamicRef` keyword survives the decode intact. + let base = doc.components.schemas["BaseCategory"]! + let childrenItems = base.objectContext!.properties["children"]!.arrayContext!.items! + XCTAssertTrue(childrenItems.isDynamicReference) + XCTAssertEqual(childrenItems.dynamicReference?.absoluteString, "#category") + + // Round-trips back out. + let reencoded = try orderUnstableEncode(doc) + let redecoded = try orderUnstableDecode(OpenAPI.Document.self, from: reencoded) + let redecodedItems = redecoded.components.schemas["BaseCategory"]! + .objectContext!.properties["children"]!.arrayContext!.items! + XCTAssertTrue(redecodedItems.isDynamicReference) + XCTAssertEqual(redecodedItems.dynamicReference?.absoluteString, "#category") + } + + // MARK: - Accessors / transformations + + func test_isDynamicReference_accessor() { + let dyn = JSONSchema.dynamicReference(.anchor("x")) + let ref = JSONSchema.reference(.component(named: "x")) + let str = JSONSchema.string + + XCTAssertTrue(dyn.isDynamicReference) + XCTAssertFalse(ref.isDynamicReference) + XCTAssertFalse(str.isDynamicReference) + + XCTAssertNotNil(dyn.dynamicReference) + XCTAssertNil(ref.dynamicReference) + XCTAssertNil(str.dynamicReference) + } + + func test_dynamicReference_optionalRequired() { + let required = JSONSchema.dynamicReference(.anchor("x")) + XCTAssertTrue(required.required) + + let optional = required.optionalSchemaObject() + XCTAssertFalse(optional.required) + XCTAssertTrue(optional.isDynamicReference) + } + + func test_dynamicReference_withDescription() { + let schema = JSONSchema.dynamicReference(.anchor("x")) + .with(description: "a recursive node") + + XCTAssertEqual(schema.description, "a recursive node") + XCTAssertTrue(schema.isDynamicReference) + } + + // MARK: - Dereferencing + + 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. + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#category" } + } + } + """ + + let schema = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + + XCTAssertThrowsError(try schema.dereferenced(in: .noComponents)) { error in + let description = String(describing: error) + XCTAssertTrue(description.contains("$dynamicRef"), "expected error to mention `$dynamicRef`, got: \(description)") + } + } +} diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md index 478f82537..1f198d44b 100644 --- a/documentation/migration_guides/v7_migration_guide.md +++ b/documentation/migration_guides/v7_migration_guide.md @@ -1,6 +1,44 @@ ## OpenAPIKit v7 Migration Guide -OpenAPIKit v7 introduces no breaking code changes (yet). +OpenAPIKit v7 introduces breaking changes to support the JSON Schema 2020-12 +`$dynamicRef` / `$dynamicAnchor` keywords (see below). The minimum Swift version has increased to Swift 6.2. +### `JSONSchema` and `DereferencedJSONSchema` gain a `.dynamicReference` case + +Support for the JSON Schema 2020-12 `$dynamicRef` / `$dynamicAnchor` keywords +([§7.7](https://json-schema.org/draft/2020-12/json-schema-core#section-7.7)) +has been added. The `JSONSchema.Schema` and `DereferencedJSONSchema` enums each +gained a new `dynamicReference(_:...)` case, and `JSONReference.InternalReference` +gained a `.anchor(String)` case. + +These are source-breaking changes for code that performs an exhaustive `switch` +over those enums: existing switches must add a case for `.dynamicReference` +(and `.anchor`, where matching `JSONReference.InternalReference` exhaustively). +Non-exhaustive usage (e.g. `if case` checks) is unaffected. + +`JSONDynamicReference` is a new type that wraps `JSONReference` and +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. + +### `$ref` with a plain fragment now round-trips verbatim + +As part of anchor support, `JSONReference.InternalReference` now parses a `$ref` +whose fragment has no leading `/` (e.g. `{"$ref": "#foo"}`) as `.anchor("foo")` +rather than `.path(...)`. The practical effect is that such references round-trip +verbatim (`"#foo"`) instead of being rewritten with a slash (`"#/foo"`). +References into the Components Object (`#/components/...`) and JSON-pointer paths +(`#/foo/bar`) are unaffected. + + From f44d178523fffbc3b4e6034bbb7b9760d979785a Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Thu, 9 Jul 2026 23:07:30 +0300 Subject: [PATCH 2/2] docs: refine v7 migration guide and mark external-deref TODO per review Addresses review feedback on #501: - Migration guide: only JSONSchema.Schema gains .dynamicReference (DereferencedJSONSchema no longer has the case); drop the paragraph describing Swift switch exhaustiveness mechanics. - DereferencedJSONSchema: add a TODO on the external-deref dynamicReference case noting external dereferencing is deferred (see #359). Part of #359. --- .../Schema Object/DereferencedJSONSchema.swift | 2 ++ documentation/migration_guides/v7_migration_guide.md | 12 +++--------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 8c46c06b0..d98a36ca2 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -676,6 +676,8 @@ extension JSONSchema: ExternallyDereferenceable { 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 = [] diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md index 1f198d44b..a10bebb54 100644 --- a/documentation/migration_guides/v7_migration_guide.md +++ b/documentation/migration_guides/v7_migration_guide.md @@ -5,18 +5,12 @@ OpenAPIKit v7 introduces breaking changes to support the JSON Schema 2020-12 The minimum Swift version has increased to Swift 6.2. -### `JSONSchema` and `DereferencedJSONSchema` gain a `.dynamicReference` case +### `JSONSchema` gains a `.dynamicReference` case Support for the JSON Schema 2020-12 `$dynamicRef` / `$dynamicAnchor` keywords ([§7.7](https://json-schema.org/draft/2020-12/json-schema-core#section-7.7)) -has been added. The `JSONSchema.Schema` and `DereferencedJSONSchema` enums each -gained a new `dynamicReference(_:...)` case, and `JSONReference.InternalReference` -gained a `.anchor(String)` case. - -These are source-breaking changes for code that performs an exhaustive `switch` -over those enums: existing switches must add a case for `.dynamicReference` -(and `.anchor`, where matching `JSONReference.InternalReference` exhaustively). -Non-exhaustive usage (e.g. `if case` checks) is unaffected. +has been added. The `JSONSchema.Schema` enum gains a new `dynamicReference(_:...)` +case, and `JSONReference.InternalReference` gains a `.anchor(String)` case. `JSONDynamicReference` is a new type that wraps `JSONReference` and encodes/decodes the `$dynamicRef` keyword. Schemas whose only attribute is