diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 709425554..763c4eb0a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -5,7 +5,7 @@ on: [pull_request] jobs: codecov: container: - image: swift:6.2 + image: swift:6.3 runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 63c93712a..d0eefbe6c 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -9,7 +9,7 @@ jobs: build: runs-on: ubuntu-latest container: - image: swift:6.2 + image: swift:6.3 steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96afb32b3..0e22960f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,9 +13,6 @@ jobs: fail-fast: false matrix: image: - - swift:6.1-focal - - swift:6.1-jammy - - swift:6.1-noble - swift:6.2-jammy - swift:6.2-noble - swift:6.3-jammy diff --git a/Package.swift b/Package.swift index 7db02f0f0..1d8deab5a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription diff --git a/README.md b/README.md index f3555d681..25bc20bfb 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,16 @@ versions and key features are supported by which OpenAPIKit versions. | OpenAPIKit | Swift | OpenAPI v3.0, v3.1 | OpenAPI v3.2 | Package Traits | |------------|-------|--------------------|--------------|----------------| -| v4.x | 5.8+ | ✅ | | | | v5.x | 5.10+ | ✅ | ✅ | | | v6.x | 6.1+ | ✅ | ✅ | ✅ | +| v7.x | 6.2+ | ✅ | ✅ | ✅ | - [Usage](#usage) - [Migration](#migration) - [Older Versions](#older-versions) - - [3.x to 4.x](#3x-to-4x) - [4.x to 5.x](#4x-to-5x) - [5.x to 6.x](#5x-to-6x) + - [6.x to 7.x](#6x-to-7x) - [Decoding OpenAPI Documents](#decoding-openapi-documents) - [Decoding Errors](#decoding-errors) - [Encoding OpenAPI Documents](#encoding-openapi-documents) @@ -58,13 +58,7 @@ versions and key features are supported by which OpenAPIKit versions. #### Older Versions - [`1.x` to `2.x`](./documentation/migration_guides/v2_migration_guide.md) - [`2.x` to `3.x`](./documentation/migration_guides/v3_migration_guide.md) - -#### 3.x to 4.x -If you are migrating from OpenAPIKit 3.x to OpenAPIKit 4.x, check out the -[v4 migration guide](./documentation/migration_guides/v4_migration_guide.md). - -Be aware of the changes to minimum Swift version and minimum Yams version -(although Yams is only a test dependency of OpenAPIKit). +- [`3.x` to `4.x`](./documentation/migration_guides/v4_migration_guide.md) #### 4.x to 5.x If you are migrating from OpenAPIKit 4.x to OpenAPIKit 5.x, check out the @@ -78,6 +72,12 @@ If you are migrating from OpenAPIKit 5.x to OpenAPIKit 6.x, check out the Be aware of the change to minimum Swift version, now Swift 6.1. +#### 6.x to 7.x +If you are migrating from OpenAPIKit 6.x to OpenAPIKit 7.x, check out the +[v7 migration guide](./documentation/migration_guides/v7_migration_guide.md). + +Be aware of the change to minimum Swift version, now Swift 6.2. + ### Decoding OpenAPI Documents Most documentation will focus on what it looks like to work with the 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..d67d8e413 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)" } } } @@ -588,16 +598,24 @@ extension JSONReference: LocallyDereferenceable where ReferenceType: LocallyDere in components: OpenAPI.Components, following references: Set, dereferencedFromComponentNamed name: String? + ) throws -> ReferenceType.DereferencedSelf { + try _dereferenced(in: components, following: references) { resolved, refs, name in + try resolved._dereferenced(in: components, following: refs, dereferencedFromComponentNamed: name) + } + } + + internal func _dereferenced( + in components: OpenAPI.Components, + following references: Set, + then: (ReferenceType, Set, String?) throws -> ReferenceType.DereferencedSelf ) throws -> ReferenceType.DereferencedSelf { var newReferences = references let (inserted, _) = newReferences.insert(self) guard inserted else { throw OpenAPI.Components.ReferenceCycleError(ref: self.absoluteString) } - - return try components - .lookup(self) - ._dereferenced(in: components, following: newReferences, dereferencedFromComponentNamed: self.name) + let resolved = try components.lookup(self) + return try then(resolved, newReferences, self.name) } } diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 5cf00e253..aa220ae00 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -305,9 +305,10 @@ extension DereferencedJSONSchema { internal init( _ arrayContext: JSONSchema.ArrayContext, resolvingIn components: OpenAPI.Components, - following references: Set + following references: Set, + dynamicScope: [String: JSONSchema] = [:] ) throws { - items = try arrayContext.items.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + items = try arrayContext.items.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } maxItems = arrayContext.maxItems _minItems = arrayContext._minItems _uniqueItems = arrayContext._uniqueItems @@ -401,17 +402,18 @@ extension DereferencedJSONSchema { internal init( _ objectContext: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components, - following references: Set + following references: Set, + dynamicScope: [String: JSONSchema] = [:] ) throws { - properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } - patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } + patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { case .a(let bool): additionalProperties = .a(bool) case .b(let schema): - additionalProperties = .b(try schema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil)) + additionalProperties = .b(try schema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope)) case nil: additionalProperties = nil } @@ -477,6 +479,20 @@ extension JSONSchema: LocallyDereferenceable { in components: OpenAPI.Components, following references: Set, dereferencedFromComponentNamed name: String? + ) throws -> DereferencedJSONSchema { + try _dereferenced(in: components, following: references, dereferencedFromComponentNamed: name, dynamicScope: [:]) + } + + /// Scope-aware dereferencing. + /// + /// `dynamicScope` maps a `$dynamicAnchor` name to the **outermost** schema + /// resource bearing that anchor on the current resolution path. Non-recursive + /// targets are inlined; recursive or unresolvable `$dynamicRef`s throw. + internal func _dereferenced( + in components: OpenAPI.Components, + following references: Set, + dereferencedFromComponentNamed name: String?, + dynamicScope outerDynamicScope: [String: JSONSchema] ) throws -> DereferencedJSONSchema { func addComponentNameExtension(to context: CoreContext) -> CoreContext { var extensions = context.vendorExtensions @@ -486,12 +502,24 @@ extension JSONSchema: LocallyDereferenceable { return context.with(vendorExtensions: extensions) } + // `$defs` anchors count as part of this resource (the JSON Schema "generics" pattern). + var dynamicScope = outerDynamicScope + if let anchor = self.dynamicAnchor, dynamicScope[anchor] == nil { + dynamicScope[anchor] = self + } + for (_, def) in self.defs { + if let defAnchor = def.dynamicAnchor, dynamicScope[defAnchor] == nil { + dynamicScope[defAnchor] = def + } + } + switch value { case .null(let coreContext): return .null(addComponentNameExtension(to: coreContext)) case .reference(let reference, let context): - var dereferenced = try reference - ._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) + var dereferenced = try reference._dereferenced(in: components, following: references) { resolved, refs, name in + try resolved._dereferenced(in: components, following: refs, dereferencedFromComponentNamed: name, dynamicScope: dynamicScope) + } if !context.required { dereferenced = dereferenced.optionalSchemaObject() @@ -508,17 +536,53 @@ extension JSONSchema: LocallyDereferenceable { dereferenced = dereferenced.with(vendorExtensions: extensions) 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 target = dynamicScope[anchorName] { + let cycleKey = AnyHashable("dynamicRef:#\(anchorName)") + if references.contains(cycleKey) { + // Recursive dynamic reference: cannot inline without retaining a + // reference, so fail -- consistent with static `$ref` cycles. + throw OpenAPI.Components.ReferenceCycleError(ref: reference.absoluteString) + } + var newReferences = references + newReferences.insert(cycleKey) + var dereferenced = try target + ._dereferenced(in: components, following: newReferences, dereferencedFromComponentNamed: nil, 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 + } + throw GenericError( + subjectName: "JSONSchema", + details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): no matching `$dynamicAnchor` found in dynamic scope.", + codingPath: [] + ) case .boolean(let context): return .boolean(addComponentNameExtension(to: context)) case .object(let coreContext, let objectContext): return try .object( addComponentNameExtension(to: coreContext), - DereferencedJSONSchema.ObjectContext(objectContext, resolvingIn: components, following: references) + DereferencedJSONSchema.ObjectContext(objectContext, resolvingIn: components, following: references, dynamicScope: dynamicScope) ) case .array(let coreContext, let arrayContext): return try .array( addComponentNameExtension(to: coreContext), - DereferencedJSONSchema.ArrayContext(arrayContext, resolvingIn: components, following: references) + DereferencedJSONSchema.ArrayContext(arrayContext, resolvingIn: components, following: references, dynamicScope: dynamicScope) ) case .number(let coreContext, let numberContext): return .number(addComponentNameExtension(to: coreContext), numberContext) @@ -527,16 +591,16 @@ extension JSONSchema: LocallyDereferenceable { case .string(let coreContext, let stringContext): return .string(addComponentNameExtension(to: coreContext), stringContext) case .all(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .all(of: schemas, core: addComponentNameExtension(to: coreContext)) case .one(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .one(of: schemas, core: addComponentNameExtension(to: coreContext)) case .any(of: let jsonSchemas, core: let coreContext): - let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let schemas = try jsonSchemas.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope) } return .any(of: schemas, core: addComponentNameExtension(to: coreContext)) case .not(let jsonSchema, core: let coreContext): - return .not(try jsonSchema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil), core: addComponentNameExtension(to: coreContext)) + return .not(try jsonSchema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil, dynamicScope: dynamicScope), core: addComponentNameExtension(to: coreContext)) case .fragment(let context): return .fragment(addComponentNameExtension(to: context)) } @@ -665,6 +729,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 .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..e0d1631da --- /dev/null +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -0,0 +1,441 @@ +// +// 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 (dynamic-scope resolution) + + func test_dereference_genericsInline() throws { + // The JSON Schema "generics" pattern: a `$dynamicAnchor` lives in `$defs` + // and the `$dynamicRef` target is a leaf (non-recursive) schema. + // Dereferencing inlines the concrete target. + let jsonString = """ + { + "$defs": { + "itemType": { + "$dynamicAnchor": "T", + "type": "string" + }, + "other": { "type": "number" } + }, + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "$dynamicRef": "#T" } + } + } + } + """ + + let box = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + let dereferenced = try box.dereferenced(in: .noComponents) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let items = try XCTUnwrap(objectContext.properties["items"]) + let itemsItems: DereferencedJSONSchema = try XCTUnwrap(items.arrayContext?.items) + + // The leaf `$defs.itemType` (`string`) was inlined through the dynamic ref. + guard case .string = itemsItems else { + XCTFail("expected dynamic ref to inline to .string, got \(itemsItems)") + return + } + } + + func test_dereference_recursiveThrows() throws { + // A self-referential schema: `Node` declares `$dynamicAnchor: node` and + // its `next` points back at `#node`. Inlining would not terminate, so + // dereferencing must throw (consistent with recursive static `$ref`). + let jsonString = """ + { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": { "type": "string" }, + "next": { "$dynamicRef": "#node" } + } + } + """ + + let node = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + + XCTAssertThrowsError(try node.dereferenced(in: .noComponents)) + } + + func test_dereference_unresolvableThrows() throws { + // A `$dynamicRef` whose anchor is not declared anywhere in scope cannot + // be resolved and must throw (rather than degrade to `any`/`unknown`). + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#unmatched" } + } + } + """ + + 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)") + } + } + + func test_dereference_scopePropagatesAcrossRefBoundary() throws { + // `Outer` references `Inner`. The dynamic anchor "leaf" lives in `Outer`'s + // `$defs`; `Inner` contains the `$dynamicRef`. The dynamic scope must + // travel across the `$ref` boundary so Inner's `$dynamicRef` resolves to + // Outer's concrete leaf type (outermost anchor wins). + let components = OpenAPI.Components( + schemas: [ + "Outer": .reference( + .component(named: "Inner"), + .init( + defs: [ + "L": .boolean(.init(dynamicAnchor: "leaf")) + ] + ) + ), + "Inner": .object( + .init(), + .init(properties: [ + "flag": .dynamicReference(.anchor("leaf")) + ]) + ) + ] + ) + + let outer = try XCTUnwrap(components.schemas["Outer"]) + let dereferenced = try outer.dereferenced(in: components) + + // Outer is a reference to Inner, so after dereferencing we see Inner's + // object shape with `flag` resolved through the dynamic scope. + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let flag: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["flag"]) + + // The dynamic ref resolved to Outer's `$defs.L` (.boolean) across the $ref. + guard case .boolean = flag else { + XCTFail("expected flag to resolve to Outer's `$defs.L` (.boolean) across the $ref, got \(flag)") + return + } + } + + func test_dereference_optionalDescribedDynamicRef() throws { + // A `$dynamicRef` that is optional (required: false) and carries a + // description: both propagate to the inlined result, mirroring `$ref`. + let item = JSONSchema.dynamicReference(.anchor("T"), required: false, description: "the item") + let box = JSONSchema.object( + .init(defs: ["T": .string(.init(dynamicAnchor: "T"), .init())]), + .init(properties: ["item": item]) + ) + + let dereferenced = try box.dereferenced(in: .noComponents) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let resolved = try XCTUnwrap(objectContext.properties["item"]) + + // Inlined to the leaf `.string`, with optionality and description preserved. + guard case .string = resolved else { + XCTFail("expected dynamic ref to inline to .string, got \(resolved)") + return + } + XCTAssertFalse(resolved.required) + XCTAssertEqual(resolved.description, "the item") + } + + 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. + let jsonString = """ + { + "type": "object", + "properties": { + "item": { "$dynamicRef": "#/components/schemas/Foo" } + } + } + """ + + let schema = try orderUnstableDecode(JSONSchema.self, from: jsonString.data(using: .utf8)!) + + XCTAssertThrowsError(try schema.dereferenced(in: .noComponents)) + } + + func test_dereference_siblingDynamicRefsResolveIndependently() throws { + // Two sibling properties each holding `$dynamicRef "#T"` resolve + // independently -- the cycle guard inserted for one must not leak to + // the other (both inline to the same leaf type). + let jsonString = """ + { + "$defs": { "T": { "$dynamicAnchor": "T", "type": "string" } }, + "type": "object", + "properties": { + "a": { "$dynamicRef": "#T" }, + "b": { "$dynamicRef": "#T" } + } + } + """ + + 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 + } + for key in ["a", "b"] { + let resolved: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties[key]) + guard case .string = resolved else { + XCTFail("expected sibling `\(key)` to inline to .string, got \(resolved)") + return + } + } + } + + func test_dereference_optionalReferenceProperty() throws { + // Covers the `.reference` optional (required: false) path threaded by + // the scope-aware dereferencer: an optional `$ref` property dereferences + // to an optional result. + let components = OpenAPI.Components(schemas: [ + "Foo": .string, + "Holder": .object( + .init(), + .init(properties: [ + "opt": .reference(.component(named: "Foo"), .init(required: false)) + ]) + ) + ]) + + 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 opt: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["opt"]) + XCTAssertFalse(opt.required) + } + + func test_dereference_dynamicRefReachedViaRef() throws { + // A `$ref` to a component that is itself a `$dynamicRef`: the component + // name propagates (dereferencedFromComponentNamed) and the dynamicRef + // resolves against the referencing schema's dynamic scope. + let components = OpenAPI.Components( + schemas: [ + "Wrapper": .object( + .init(defs: ["T": .string(.init(dynamicAnchor: "T"), .init())]), + .init(properties: [ + "item": .reference(.component(named: "DynRef")) + ]) + ), + "DynRef": .dynamicReference(.anchor("T")) + ] + ) + + let wrapper = try XCTUnwrap(components.schemas["Wrapper"]) + let dereferenced = try wrapper.dereferenced(in: components) + + guard case .object(_, let objectContext) = dereferenced else { + XCTFail("expected .object, got \(dereferenced)") + return + } + let item: DereferencedJSONSchema = try XCTUnwrap(objectContext.properties["item"]) + + // $ref DynRef -> DynRef is $dynamicRef #T -> resolves to Wrapper's $defs.T (.string). + guard case .string = item else { + XCTFail("expected item to resolve to .string via $ref -> $dynamicRef, got \(item)") + return + } + } +} diff --git a/documentation/migration_guides/v7_migration_guide.md b/documentation/migration_guides/v7_migration_guide.md new file mode 100644 index 000000000..4402109cd --- /dev/null +++ b/documentation/migration_guides/v7_migration_guide.md @@ -0,0 +1,33 @@ +## 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` 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` 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 +`$dynamicRef` now decode as `.dynamicReference` instead of decoding as an empty +`.fragment` with an "unsupported attributes" warning. + +### Local dereferencing resolves `$dynamicRef` + +`locallyDereferenced()` and `JSONSchema.dereferenced(in:)` now resolve +`$dynamicRef` against the dynamic scope. + +### `$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. +