diff --git a/Sources/OpenAPIKit/JSONDynamicReference.swift b/Sources/OpenAPIKit/JSONDynamicReference.swift new file mode 100644 index 000000000..b476f6a12 --- /dev/null +++ b/Sources/OpenAPIKit/JSONDynamicReference.swift @@ -0,0 +1,170 @@ +// +// JSONDynamicReference.swift +// +// +// Created by Mathew Polzin on 3/19/24. +// + +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`; it does not perform +/// dynamic-scope evaluation, which is a runtime concern belonging to JSON +/// Schema validators rather than the OpenAPI document model. Local +/// dereferencing (`locallyDereferenced()`) resolves a `$dynamicRef` to the +/// outermost in-scope `$dynamicAnchor` where the dynamic scope can be +/// determined from the document's static structure; on cycles and +/// unresolvable references the dynamic reference is preserved as-is. +@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 component of type `JSONSchema` in the + /// Components Object. + /// + /// Example: + /// + /// JSONDynamicReference.component(named: "greetings") + /// // encoded string: "#/components/schemas/greetings" + /// // Swift: `document.components.schemas["greetings"]` + public static func component(named name: String) -> Self { + return .init(.internal(.component(name: name))) + } + + /// 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))) + } + + /// Reference a path internal to this file but not within the Components Object. + public static func `internal`(path: JSONReference.Path) -> Self { + return .init(.internal(.path(path))) + } + + /// Reference an external URL. + public static func external(_ url: URL) -> Self { + return .init(.external(url)) + } + + /// `true` for internal references, `false` for + /// external references (i.e. to another file). + public var isInternal: Bool { + return jsonReference.isInternal + } + + /// `true` for external references, `false` for + /// internal references. + public var isExternal: Bool { + return jsonReference.isExternal + } + + /// Get the name of the referenced object. This method returns optional + /// because a reference to an external file might not have any path if the + /// file itself is the referenced component. + public var name: String? { + return jsonReference.name + } + + /// The absolute value of an external reference's + /// URL or the path fragment string for a local + /// reference as defined in [RFC 3986](https://tools.ietf.org/html/rfc3986). + public var absoluteString: String { + return jsonReference.absoluteString + } +} + +public extension JSONReference where ReferenceType == JSONSchema { + /// Create a `JSONDynamicReference` from this `JSONReference`. + var dynamicReference: JSONDynamicReference { + JSONDynamicReference(self) + } +} + +// 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.count > 0 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)) + } + } +} + +extension JSONDynamicReference: Validatable {} diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index fcf7e0119..ccd09d934 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,6 +171,12 @@ public enum JSONReference: Equatabl } let fragment = rawValue.dropFirst() guard fragment.starts(with: "/components") else { + // A fragment that does not start with '/' (e.g. "#category") + // is a plain anchor reference used by `$anchor` / `$dynamicAnchor`. + guard fragment.first == "/" else { + self = .anchor(String(fragment)) + return + } self = .path(Path(rawValue: String(fragment))) return } @@ -192,6 +203,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..c0be272b9 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -27,6 +27,13 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { indirect case one(of: [DereferencedJSONSchema], core: CoreContext) indirect case any(of: [DereferencedJSONSchema], core: CoreContext) indirect case not(DereferencedJSONSchema, core: CoreContext) + /// A `$dynamicRef` that survived local dereferencing, either because its + /// dynamic scope could not be determined from the static document or + /// because resolving it would reintroduce a cycle. Dynamic references are + /// only resolved by `locallyDereferenced()` where the dynamic scope is + /// known; a `DereferencedJSONSchema` retains one when it cannot be + /// meaningfully inlined. + case dynamicReference(JSONDynamicReference, CoreContext) /// Schemas without a `type`. case fragment(CoreContext) // This is the "{}" case where not even a type constraint is given. @@ -65,6 +72,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { return .any(of: schemas.map { $0.jsonSchema }, core: coreContext) case .not(let schema, core: let coreContext): return .not(schema.jsonSchema, core: coreContext) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext) case .fragment(let context): return .fragment(context) } @@ -96,6 +105,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { return .any(of: schemas, core: core.optionalContext()) case .not(let schema, core: let core): return .not(schema, core: core.optionalContext()) + case .dynamicReference(let reference, let core): + return .dynamicReference(reference, core.optionalContext()) } } @@ -182,6 +193,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { return coreContext.vendorExtensions case .not(_, core: let coreContext): return coreContext.vendorExtensions + case .dynamicReference(_, let coreContext): + return coreContext.vendorExtensions case .fragment(let context): return context.vendorExtensions } @@ -212,6 +225,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { return .any(of: schemas, core: coreContext.with(description: description)) case .not(let schema, core: let coreContext): return .not(schema, core: coreContext.with(description: description)) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext.with(description: description)) case .fragment(let context): return .fragment(context.with(description: description)) } @@ -242,6 +257,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable { return .any(of: schemas, core: coreContext.with(vendorExtensions: vendorExtensions)) case .not(let schema, core: let coreContext): return .not(schema, core: coreContext.with(vendorExtensions: vendorExtensions)) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext.with(vendorExtensions: vendorExtensions)) case .fragment(let context): return .fragment(context.with(vendorExtensions: vendorExtensions)) } @@ -508,6 +525,10 @@ extension JSONSchema: LocallyDereferenceable { dereferenced = dereferenced.with(vendorExtensions: extensions) return dereferenced + case .dynamicReference(let reference, let context): + // Parse/preserve only: a `$dynamicRef` survives local dereferencing + // unchanged. Dynamic-scope resolution is tracked as a follow-up in #359. + return .dynamicReference(reference, addComponentNameExtension(to: context)) case .boolean(let context): return .boolean(addComponentNameExtension(to: context)) case .object(let coreContext, let objectContext): @@ -665,6 +686,10 @@ extension JSONSchema: ExternallyDereferenceable { newSchema = .init( schema: .reference(newReference, core) ) + case .dynamicReference(let reference, let core): + 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..e8b3acc49 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 } @@ -369,15 +375,30 @@ extension JSONSchema { } /// Check if a schema is a `.reference`. + /// + /// This returns `false` if the schema is a + /// `.dynamicReference` even though a + /// "dynamic reference" is a kind of "reference." 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 +420,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 +562,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 +595,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 +668,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 +744,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 +815,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.nullableContext()) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -848,6 +884,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 +960,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 +1043,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 +1114,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.with(discriminator: discriminator)) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -1131,6 +1182,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 +1986,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 +2109,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 +2150,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..b23e3995e --- /dev/null +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -0,0 +1,218 @@ +// +// 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 (parse/preserve) + + func test_dereference_dynamicReferenceSurvivesUnchanged() throws { + // At this stage (parse/preserve) a `$dynamicRef` is not resolved against + // the dynamic scope; it must survive local dereferencing unchanged + // rather than being dropped or degraded to an empty/`any` schema. + // Dynamic-scope resolution is tracked as a follow-up in #359. + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#category" } + } + } + """ + + 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 + } + let item: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["item"]) + + if case .dynamicReference(let ref, _) = item { + XCTAssertEqual(ref.absoluteString, "#category") + } else { + XCTFail("expected `$dynamicRef` to be preserved through dereferencing, got \(item)") + } + } +} diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md new file mode 100644 index 000000000..dfaafe37f --- /dev/null +++ b/documentation/migration_guides/v7_migration_guide.md @@ -0,0 +1,35 @@ +## OpenAPIKit v7 Migration Guide + +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. + +### `$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. + +