From 0cd62a51a2bb954cf99c6f8779c63d6cf21848df Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 13 Nov 2025 10:17:14 -0600 Subject: [PATCH 1/4] allow components object to contain references in its entries --- .../Components+JSONReference.swift | 265 +++++++++++++++--- .../Components+Locatable.swift | 22 +- .../Components Object/Components.swift | 49 ++-- Sources/OpenAPIKit/JSONReference.swift | 21 +- Sources/OpenAPIKitCompat/Compat30To31.swift | 16 +- 5 files changed, 297 insertions(+), 76 deletions(-) diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index 0ca1f5caa..67e46a97e 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -10,30 +10,36 @@ import OpenAPIKitCore extension OpenAPI.Components { /// Check if the `Components` contains the given reference or not. /// - /// Look up a reference in this components dictionary. If you want a - /// non-throwing alternative, you can pull a `JSONReference.InternalReference` - /// out of the `reference` (which is of type `JSONReference`) and pass that to `contains` - /// instead. + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + /// + /// If you want a non-throwing alternative, you can pull a + /// `JSONReference.InternalReference` out of the `reference` and pass that + /// to `contains` instead. /// /// - Throws: If the given reference cannot be checked against `Components` /// then this method will throw `ReferenceError`. This will occur when /// the given reference is a remote file reference. - public func contains(_ reference: OpenAPI.Reference) throws -> Bool { + public func contains(_ reference: OpenAPI.Reference) throws -> Bool { return try contains(reference.jsonReference) } /// Check if the `Components` contains the given reference or not. /// - /// Look up a reference in this components dictionary. If you want a - /// non-throwing alternative, you can pull a `JSONReference.InternalReference` - /// out of your `JSONReference` and pass that to `contains` - /// instead. + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + /// + /// If you want a non-throwing alternative, you can pull a + /// `JSONReference.InternalReference` out of your `reference` and pass that + /// to `contains` instead. /// /// - Throws: If the given reference cannot be checked against `Components` /// then this method will throw `ReferenceError`. This will occur when /// the given reference is a remote file reference. - public func contains(_ reference: JSONReference) throws -> Bool { + public func contains(_ reference: JSONReference) throws -> Bool { guard case .internal(let localReference) = reference else { throw ReferenceError.cannotLookupRemoteReference } @@ -42,16 +48,34 @@ extension OpenAPI.Components { } /// Check if the `Components` contains the given internal reference or not. - public func contains(_ reference: JSONReference.InternalReference) -> Bool { - return reference.name - .flatMap(OpenAPI.ComponentKey.init(rawValue:)) - .map { self[keyPath: ReferenceType.openAPIComponentsKeyPath].contains(key: $0) } - ?? false + /// + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + public func contains(_ reference: JSONReference.InternalReference) -> Bool { + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + return reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .map { self[keyPath: directPath].contains(key: $0) } + ?? false + case .b(let referencePath): + return reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .map { self[keyPath: referencePath].contains(key: $0) } + ?? false + } } - /// Retrieve a referenced item from the `Components` or - /// just return the item directly depending on what value - /// the `Either` contains. + /// Retrieve a referenced item from the `Components` or just return the + /// item directly depending on what value the `Either` contains. + /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// + /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ maybeReference: Either, ReferenceType>) -> ReferenceType? { switch maybeReference { case .a(let reference): @@ -63,6 +87,11 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: OpenAPI.Reference) -> ReferenceType? { @@ -71,6 +100,11 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: JSONReference) -> ReferenceType? { guard case .internal(let localReference) = reference else { @@ -82,11 +116,57 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: JSONReference.InternalReference) -> ReferenceType? { - return reference.name - .flatMap(OpenAPI.ComponentKey.init(rawValue:)) - .flatMap { self[keyPath: ReferenceType.openAPIComponentsKeyPath][$0] } + return try? lookup(reference) + } + + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// If the `OpenAPI.Reference` has a `summary` or `description` then the referenced + /// object will have its `summary` and/or `description` overridden by that of the reference. + /// This only applies if the referenced object would normally have a summary/description. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: OpenAPI.Reference) throws -> Either, ReferenceType> { + let value = try lookupOnce(reference.jsonReference) + + switch value { + case .a(let reference): + return .a( + reference + .overriddenNonNil(summary: reference.summary) + .overriddenNonNil(description: reference.description) + ) + + case .b(let direct): + return .b( + direct + .overriddenNonNil(summary: reference.summary) + .overriddenNonNil(description: reference.description) + ) + } } /// Pass a reference to a component. @@ -108,9 +188,8 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ reference: OpenAPI.Reference) throws -> ReferenceType { return try lookup(reference.jsonReference) @@ -118,6 +197,32 @@ extension OpenAPI.Components { .overriddenNonNil(description: reference.description) } + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: JSONReference) throws -> Either, ReferenceType> { + guard case let .internal(internalReference) = reference else { + throw ReferenceError.cannotLookupRemoteReference + } + return try lookupOnce(internalReference) + } + /// Pass a reference to a component. /// `lookup()` will return the component value if it is found /// in the Components Object. @@ -133,19 +238,116 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ reference: JSONReference) throws -> ReferenceType { - guard case .internal = reference else { + guard case let .internal(internalReference) = reference else { throw ReferenceError.cannotLookupRemoteReference } - guard let value = self[reference] else { + return try lookup(internalReference) + } + + internal func _lookup(_ reference: JSONReference, following visitedReferences: Set = .init()) throws -> ReferenceType { + guard case let .internal(internalReference) = reference else { + throw ReferenceError.cannotLookupRemoteReference + } + return try _lookup(internalReference, following: visitedReferences) + } + + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: JSONReference.InternalReference) throws -> Either, ReferenceType> { + let value: Either, ReferenceType>? + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + value = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: directPath][$0] } + .map { .b($0) } + + case .b(let referencePath): + value = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: referencePath][$0] } + } + guard let value else { throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) } return value } + /// Pass a reference to a component. + /// `lookup()` will return the component value if it is found + /// in the Components Object. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` + public func lookup(_ reference: JSONReference.InternalReference) throws -> ReferenceType { + return try _lookup(reference) + } + + internal func _lookup(_ reference: JSONReference.InternalReference, following visitedReferences: Set = .init()) throws -> ReferenceType { + if visitedReferences.contains(reference) { + throw ReferenceCycleError(ref: reference.rawValue) + } + + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + let value: ReferenceType? = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: directPath][$0] } + + guard let value else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + return value + + case .b(let referencePath): + let possibleValue: Either, ReferenceType>? = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: referencePath][$0] } + + guard let possibleValue else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + + switch possibleValue { + case .a(let newReference): + return try _lookup(newReference.jsonReference, following: visitedReferences.union([reference])) + case .b(let value): + return value + } + } + } + /// Pass an `Either` with a reference or a component. /// `lookup()` will return the component value if it is found /// in the Components Object. @@ -161,9 +363,8 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ maybeReference: Either, ReferenceType>) throws -> ReferenceType { switch maybeReference { case .a(let reference): diff --git a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift index c1a56f0f0..441ba805f 100644 --- a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift +++ b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift @@ -16,57 +16,57 @@ public protocol ComponentDictionaryLocatable: SummaryOverridable { /// This can be used to create a JSON path /// like `#/name1/name2/name3` static var openAPIComponentsKey: String { get } - static var openAPIComponentsKeyPath: WritableKeyPath> { get } + static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { get } } extension JSONSchema: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "schemas" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.schemas } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .a(\.schemas) } } extension OpenAPI.Response: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "responses" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.responses } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.responses) } } extension OpenAPI.Callbacks: ComponentDictionaryLocatable & SummaryOverridable { public static var openAPIComponentsKey: String { "callbacks" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.callbacks } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.callbacks) } } extension OpenAPI.Parameter: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "parameters" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.parameters } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.parameters) } } extension OpenAPI.Example: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "examples" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.examples } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.examples) } } extension OpenAPI.Request: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "requestBodies" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.requestBodies } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.requestBodies) } } extension OpenAPI.Header: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "headers" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.headers } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.headers) } } extension OpenAPI.SecurityScheme: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "securitySchemes" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.securitySchemes } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.securitySchemes) } } extension OpenAPI.Link: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "links" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.links } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.links) } } extension OpenAPI.PathItem: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "pathItems" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.pathItems } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .a(\.pathItems) } } /// A dereferenceable type can be recursively looked up in diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 284ca030f..3218b1342 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -18,14 +18,14 @@ extension OpenAPI { public struct Components: Equatable, CodableVendorExtendable, Sendable { public var schemas: ComponentDictionary - public var responses: ComponentDictionary - public var parameters: ComponentDictionary - public var examples: ComponentDictionary - public var requestBodies: ComponentDictionary - public var headers: ComponentDictionary
- public var securitySchemes: ComponentDictionary - public var links: ComponentDictionary - public var callbacks: ComponentDictionary + public var responses: ComponentReferenceDictionary + public var parameters: ComponentReferenceDictionary + public var examples: ComponentReferenceDictionary + public var requestBodies: ComponentReferenceDictionary + public var headers: ComponentReferenceDictionary
+ public var securitySchemes: ComponentReferenceDictionary + public var links: ComponentReferenceDictionary + public var callbacks: ComponentReferenceDictionary public var pathItems: ComponentDictionary @@ -38,14 +38,14 @@ extension OpenAPI { public init( schemas: ComponentDictionary = [:], - responses: ComponentDictionary = [:], - parameters: ComponentDictionary = [:], - examples: ComponentDictionary = [:], - requestBodies: ComponentDictionary = [:], - headers: ComponentDictionary
= [:], - securitySchemes: ComponentDictionary = [:], - links: ComponentDictionary = [:], - callbacks: ComponentDictionary = [:], + responses: ComponentReferenceDictionary = [:], + parameters: ComponentReferenceDictionary = [:], + examples: ComponentReferenceDictionary = [:], + requestBodies: ComponentReferenceDictionary = [:], + headers: ComponentReferenceDictionary
= [:], + securitySchemes: ComponentReferenceDictionary = [:], + links: ComponentReferenceDictionary = [:], + callbacks: ComponentReferenceDictionary = [:], pathItems: ComponentDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { @@ -133,6 +133,7 @@ extension OpenAPI.Components { extension OpenAPI { public typealias ComponentDictionary = OrderedDictionary + public typealias ComponentReferenceDictionary = OrderedDictionary, T>> } // MARK: - Codable @@ -194,26 +195,26 @@ extension OpenAPI.Components: Decodable { schemas = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .schemas) ?? [:] - responses = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .responses) + responses = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .responses) ?? [:] - parameters = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .parameters) + parameters = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .parameters) ?? [:] - examples = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .examples) + examples = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .examples) ?? [:] - requestBodies = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .requestBodies) + requestBodies = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .requestBodies) ?? [:] - headers = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .headers) + headers = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .headers) ?? [:] - securitySchemes = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .securitySchemes) ?? [:] + securitySchemes = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .securitySchemes) ?? [:] - links = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .links) ?? [:] + links = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .links) ?? [:] - callbacks = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .callbacks) ?? [:] + callbacks = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .callbacks) ?? [:] pathItems = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .pathItems) ?? [:] diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index 4966193d1..bc7c8a502 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -428,6 +428,20 @@ public protocol OpenAPISummarizable: OpenAPIDescribable { func overriddenNonNil(summary: String?) -> Self } +extension OpenAPI.Reference: OpenAPISummarizable { + public func overriddenNonNil(summary: String?) -> Self { + guard let summary else { return self } + + return .init(jsonReference, summary: summary, description: description) + } + + public func overriddenNonNil(description: String?) -> Self { + guard let description else { return self } + + return .init(jsonReference, summary: summary, description: description) + } +} + // MARK: - Codable extension JSONReference { @@ -558,7 +572,12 @@ extension JSONReference: ExternallyDereferenceable where ReferenceType: External let componentKey = try loader.componentKey(type: ReferenceType.self, at: url) let (component, messages): (ReferenceType, [Loader.Message]) = try await loader.load(url) var components = OpenAPI.Components() - components[keyPath: ReferenceType.openAPIComponentsKeyPath][componentKey] = component + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + components[keyPath: directPath][componentKey] = component + case .b(let referencePath): + components[keyPath: referencePath][componentKey] = .b(component) + } return (try components.reference(named: componentKey.rawValue, ofType: ReferenceType.self).jsonReference, components, messages) } } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index a80d74b3c..2ab98b06c 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -652,14 +652,14 @@ extension OpenAPIKit30.OpenAPI.Components: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Components { OpenAPIKit.OpenAPI.Components( schemas: schemas.mapValues { $0.to31() }, - responses: responses.mapValues { $0.to31() }, - parameters: parameters.mapValues { $0.to31() }, - examples: examples.mapValues { $0.to31() }, - requestBodies: requestBodies.mapValues { $0.to31() }, - headers: headers.mapValues { $0.to31() }, - securitySchemes: securitySchemes.mapValues { $0.to31() }, - links: links.mapValues { $0.to31() }, - callbacks: callbacks.mapValues { $0.to31() }, + responses: responses.mapValues { .b($0.to31()) }, + parameters: parameters.mapValues { .b($0.to31()) }, + examples: examples.mapValues { .b($0.to31()) }, + requestBodies: requestBodies.mapValues { .b($0.to31()) }, + headers: headers.mapValues { .b($0.to31()) }, + securitySchemes: securitySchemes.mapValues { .b($0.to31()) }, + links: links.mapValues { .b($0.to31()) }, + callbacks: callbacks.mapValues { .b($0.to31()) }, vendorExtensions: vendorExtensions ) } From e83da2a62a6054ba2504c678bf574c2c544906b0 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 17 Nov 2025 10:59:56 -0600 Subject: [PATCH 2/4] add new direct convenience constructor, update tests, add documentation --- .../Components+JSONReference.swift | 2 +- .../Components Object/Components.swift | 79 ++++++++- .../Either/Either+Convenience.swift | 25 +++ .../OpenAPIDecodingErrors.swift | 4 +- .../DocumentConversionTests.swift | 52 ++++-- Tests/OpenAPIKitTests/ComponentsTests.swift | 150 +++++++++++++++++- .../Content/DereferencedContentTests.swift | 6 +- .../Document/DereferencedDocumentTests.swift | 4 +- .../Document/DocumentTests.swift | 4 +- .../Document/ResolvedDocumentTests.swift | 2 +- Tests/OpenAPIKitTests/EaseOfUseTests.swift | 8 +- .../OpenAPIReferenceTests.swift | 2 +- .../DereferencedOperationTests.swift | 10 +- .../Operation/ResolvedEndpointTests.swift | 8 +- .../DereferencedSchemaContextTests.swift | 4 +- .../Path Item/DereferencedPathItemTests.swift | 24 +-- .../Response/DereferencedResponseTests.swift | 4 +- .../DereferencedSchemaObjectTests.swift | 2 +- .../Validator/BuiltinValidationTests.swift | 6 +- .../Validation+ConvenienceTests.swift | 4 +- 20 files changed, 330 insertions(+), 70 deletions(-) diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index 67e46a97e..fc709c9b2 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -416,7 +416,7 @@ extension OpenAPI.Components { public let ref: String public var description: String { - return "Encountered a JSON Schema $ref cycle that prevents fully dereferencing document at '\(ref)'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy." + return "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at '\(ref)'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case." } public var localizedDescription: String { diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 3218b1342..035644c43 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -15,6 +15,36 @@ extension OpenAPI { /// /// This is a place to put reusable components to /// be referenced from other parts of the spec. + /// + /// Most of the components dictionaries can contain either the component + /// directly or a $ref to the component. This distinction can be seen in + /// the types as either `ComponentDictionary` (direct) or + /// `ComponentReferenceDictionary` (direct or by-reference). + /// + /// If you are building a Components Object in Swift you may choose to make + /// all of your components direct in which case the + /// `OpenAPI.Components.direct()` convenience constructor will save you + /// some typing and verbosity. + /// + /// **Example** + /// OpenAPI.Components( + /// parameters: [ "my_param": .parameter(.cookie(name: "my_param", schema: .string)) ] + /// ) + /// + /// // The above value is the same as the below value + /// + /// OpenAPI.Components.direct( + /// parameters: [ "my_param": .cookie(name: "my_param", schema: .string) ] + /// ) + /// + /// // However, the `init()` initializer does allow you to use references where desired + /// + /// OpenAPI.Components( + /// parameters: [ + /// "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + /// "my_param": .reference(.component(named: "my_direct_param")) + /// ] + /// ) public struct Components: Equatable, CodableVendorExtendable, Sendable { public var schemas: ComponentDictionary @@ -62,6 +92,37 @@ extension OpenAPI { self.vendorExtensions = vendorExtensions } + /// Construct components as "direct" entries (no references). When + /// building a document in Swift code, this is often sufficient and it + /// means you don't need to wrap every entry in an `Either`. + public static func direct( + schemas: ComponentDictionary = [:], + responses: ComponentDictionary = [:], + parameters: ComponentDictionary = [:], + examples: ComponentDictionary = [:], + requestBodies: ComponentDictionary = [:], + headers: ComponentDictionary
= [:], + securitySchemes: ComponentDictionary = [:], + links: ComponentDictionary = [:], + callbacks: ComponentDictionary = [:], + pathItems: ComponentDictionary = [:], + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + schemas: schemas, + responses: responses.mapValues { .b($0) }, + parameters: parameters.mapValues { .b($0) }, + examples: examples.mapValues { .b($0) }, + requestBodies: requestBodies.mapValues { .b($0) }, + headers: headers.mapValues { .b($0) }, + securitySchemes: securitySchemes.mapValues { .b($0) }, + links: links.mapValues { .b($0) }, + callbacks: callbacks.mapValues { .b($0) }, + pathItems: pathItems, + vendorExtensions: vendorExtensions + ) + } + /// An empty OpenAPI Components Object. public static let noComponents: Components = .init() @@ -71,6 +132,12 @@ extension OpenAPI { } } +extension OpenAPI { + + public typealias ComponentDictionary = OrderedDictionary + public typealias ComponentReferenceDictionary = OrderedDictionary, T>> +} + extension OpenAPI.Components { public struct ComponentCollision: Swift.Error { public let componentType: String @@ -130,12 +197,6 @@ extension OpenAPI.Components { public static let componentNameExtension: String = "x-component-name" } -extension OpenAPI { - - public typealias ComponentDictionary = OrderedDictionary - public typealias ComponentReferenceDictionary = OrderedDictionary, T>> -} - // MARK: - Codable extension OpenAPI.Components: Encodable { public func encode(to encoder: Encoder) throws { @@ -219,6 +280,12 @@ extension OpenAPI.Components: Decodable { pathItems = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .pathItems) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + } catch let error as EitherDecodeNoTypesMatchedError { + if let underlyingError = OpenAPI.Error.Decoding.Document.eitherBranchToDigInto(error) { + throw (underlyingError.underlyingError ?? underlyingError) + } + + throw error } catch let error as DecodingError { if let underlyingError = error.underlyingError as? KeyDecodingError { throw GenericError( diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index f9cd238dc..bbc414460 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -131,6 +131,16 @@ extension Either where B == OpenAPI.Header { public var headerValue: B? { b } } +extension Either where B == OpenAPI.Callbacks { + /// Retrieve the callbacks if that is what this property contains. + public var callbacksValue: B? { b } +} + +extension Either where B == OpenAPI.SecurityScheme { + /// Retrieve the security scheme if that is what this property contains. + public var securitySchemeValue: B? { b } +} + // MARK: - Convenience constructors extension Either where A == Bool { /// Construct a boolean value. @@ -220,7 +230,22 @@ extension Either where B == OpenAPI.Response { public static func response(_ response: OpenAPI.Response) -> Self { .b(response) } } +extension Either where B == OpenAPI.Link { + /// Construct a link value. + public static func link(_ link: OpenAPI.Link) -> Self { .b(link) } +} + extension Either where B == OpenAPI.Header { /// Construct a header value. public static func header(_ header: OpenAPI.Header) -> Self { .b(header) } } + +extension Either where B == OpenAPI.Callbacks { + /// Construct a callbacks value. + public static func callbacks(_ callbacks: OpenAPI.Callbacks) -> Self { .b(callbacks) } +} + +extension Either where B == OpenAPI.SecurityScheme { + /// Construct a security scheme value. + public static func securityScheme(_ securityScheme: OpenAPI.SecurityScheme) -> Self { .b(securityScheme) } +} diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift index 4111ab04c..760122c9d 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift @@ -10,7 +10,7 @@ extension Error { public enum Decoding {} } -public enum ErrorCategory { +public enum ErrorCategory: Sendable { /// The type with the given name was expected but not found. case typeMismatch(expectedTypeName: String) /// One of two possible types were expected but neither was found. @@ -22,7 +22,7 @@ public enum ErrorCategory { /// Something inconsistent or disallowed according the OpenAPI Specification was found. case inconsistency(details: String) - public enum KeyValue { + public enum KeyValue: Sendable { case key case value } diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 11a0c75b0..9568e15e1 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -834,8 +834,8 @@ final class DocumentConversionTests: XCTestCase { try assertEqualNewToOld(newDoc,oldDoc) - let newParameter1 = try XCTUnwrap(newDoc.components.parameters["param1"]) - let newParameter2 = try XCTUnwrap(newDoc.components.parameters["param2"]) + let newParameter1 = try XCTUnwrap(newDoc.components.parameters["param1"]?.b) + let newParameter2 = try XCTUnwrap(newDoc.components.parameters["param2"]?.b) try assertEqualNewToOld(newParameter1, parameter1) try assertEqualNewToOld(newParameter2, parameter2) @@ -1497,36 +1497,68 @@ fileprivate func assertEqualNewToOld(_ newComponents: OpenAPIKit.OpenAPI.Compone let oldSchema = try XCTUnwrap(oldComponents.schemas[key]) try assertEqualNewToOld(newSchema, oldSchema) } - for (key, newResponse) in newComponents.responses { + for (key, maybeNewResponse) in newComponents.responses { let oldResponse = try XCTUnwrap(oldComponents.responses[key]) + guard case let .b(newResponse) = maybeNewResponse else { + XCTFail("Found a reference to a response where one was not expected") + return + } try assertEqualNewToOld(newResponse, oldResponse) } - for (key, newParameter) in newComponents.parameters { + for (key, maybeNewParameter) in newComponents.parameters { let oldParameter = try XCTUnwrap(oldComponents.parameters[key]) + guard case let .b(newParameter) = maybeNewParameter else { + XCTFail("Found a reference to a parameter where one was not expected") + return + } try assertEqualNewToOld(newParameter, oldParameter) } - for (key, newExample) in newComponents.examples { + for (key, maybeNewExample) in newComponents.examples { let oldExample = try XCTUnwrap(oldComponents.examples[key]) + guard case let .b(newExample) = maybeNewExample else { + XCTFail("Found a reference to an example where one was not expected") + return + } assertEqualNewToOld(newExample, oldExample) } - for (key, newRequest) in newComponents.requestBodies { + for (key, maybeNewRequest) in newComponents.requestBodies { let oldRequest = try XCTUnwrap(oldComponents.requestBodies[key]) + guard case let .b(newRequest) = maybeNewRequest else { + XCTFail("Found a reference to a request where one was not expected") + return + } try assertEqualNewToOld(newRequest, oldRequest) } - for (key, newHeader) in newComponents.headers { + for (key, maybeNewHeader) in newComponents.headers { let oldHeader = try XCTUnwrap(oldComponents.headers[key]) + guard case let .b(newHeader) = maybeNewHeader else { + XCTFail("Found a reference to a header where one was not expected") + return + } try assertEqualNewToOld(newHeader, oldHeader) } - for (key, newSecurity) in newComponents.securitySchemes { + for (key, maybeNewSecurity) in newComponents.securitySchemes { let oldSecurity = try XCTUnwrap(oldComponents.securitySchemes[key]) + guard case let .b(newSecurity) = maybeNewSecurity else { + XCTFail("Found a reference to a security scheme where one was not expected") + return + } try assertEqualNewToOld(newSecurity, oldSecurity) } - for (key, newLink) in newComponents.links { + for (key, maybeNewLink) in newComponents.links { let oldLink = try XCTUnwrap(oldComponents.links[key]) + guard case let .b(newLink) = maybeNewLink else { + XCTFail("Found a reference to a link where one was not expected") + return + } try assertEqualNewToOld(newLink, oldLink) } - for (key, newCallbacks) in newComponents.callbacks { + for (key, maybeNewCallbacks) in newComponents.callbacks { let oldCallbacks = try XCTUnwrap(oldComponents.callbacks[key]) + guard case let .b(newCallbacks) = maybeNewCallbacks else { + XCTFail("Found a reference to a callbacks object where one was not expected") + return + } for (key, newCallback) in newCallbacks { let oldPathItem = try XCTUnwrap(oldCallbacks[key]) switch (newCallback) { diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 8f603962b..3a7ad1d35 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -25,11 +25,111 @@ final class ComponentsTests: XCTestCase { XCTAssertFalse(c3.isEmpty) } + func test_directConstructor() { + let c1 = OpenAPI.Components( + schemas: [ + "one": .string + ], + responses: [ + "two": .response(.init(description: "hello", content: [:])) + ], + parameters: [ + "three": .parameter(.init(name: "hi", context: .query(content: [:]))) + ], + examples: [ + "four": .example(.init(value: .init(URL(string: "http://address.com")!))) + ], + requestBodies: [ + "five": .request(.init(content: [:])) + ], + headers: [ + "six": .header(.init(schema: .string)) + ], + securitySchemes: [ + "seven": .securityScheme(.http(scheme: "cool")) + ], + links: [ + "eight": .link(.init(operationId: "op1")) + ], + callbacks: [ + "nine": .callbacks([ + OpenAPI.CallbackURL(rawValue: "{$request.query.queryUrl}")!: .pathItem( + .init( + post: .init( + responses: [ + 200: .response( + description: "callback successfully processed" + ) + ] + ) + ) + ) + ]) + ], + pathItems: [ + "ten": .init(get: .init(responses: [200: .response(description: "response")])) + ], + vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] + ) + + let c2 = OpenAPI.Components.direct( + schemas: [ + "one": .string + ], + responses: [ + "two": .init(description: "hello", content: [:]) + ], + parameters: [ + "three": .init(name: "hi", context: .query(content: [:])) + ], + examples: [ + "four": .init(value: .init(URL(string: "http://address.com")!)) + ], + requestBodies: [ + "five": .init(content: [:]) + ], + headers: [ + "six": .init(schema: .string) + ], + securitySchemes: [ + "seven": .http(scheme: "cool") + ], + links: [ + "eight": .init(operationId: "op1") + ], + callbacks: [ + "nine": [ + OpenAPI.CallbackURL(rawValue: "{$request.query.queryUrl}")!: .pathItem( + .init( + post: .init( + responses: [ + 200: .response( + description: "callback successfully processed" + ) + ] + ) + ) + ) + ] + ], + pathItems: [ + "ten": .init(get: .init(responses: [200: .response(description: "response")])) + ], + vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] + ) + + XCTAssertEqual(c1, c2) + } + func test_referenceLookup() throws { let components = OpenAPI.Components( schemas: [ "hello": .string, "world": .integer(required: false) + ], + parameters: [ + "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + "my_param": .reference(.component(named: "my_direct_param")) ] ) @@ -50,11 +150,30 @@ final class ComponentsTests: XCTestCase { XCTAssertNil(components[ref5]) XCTAssertNil(components[ref6]) - let ref7 = JSONReference.external(URL(string: "hello.json")!) + let ref7 = JSONReference.component(named: "my_param") + + XCTAssertEqual(components[ref7], .cookie(name: "my_param", schema: .string)) + + let ref8 = JSONReference.external(URL(string: "hello.json")!) + + XCTAssertNil(components[ref8]) + + XCTAssertThrowsError(try components.contains(ref8)) + } + + func test_lookupOnce() throws { + let components = OpenAPI.Components( + parameters: [ + "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + "my_param": .reference(.component(named: "my_direct_param")) + ] + ) - XCTAssertNil(components[ref7]) + let ref1 = JSONReference.component(named: "my_param") + let ref2 = JSONReference.component(named: "my_direct_param") - XCTAssertThrowsError(try components.contains(ref7)) + XCTAssertEqual(try components.lookupOnce(ref1), .reference(.component(named: "my_direct_param"))) + XCTAssertEqual(try components.lookupOnce(ref2), .parameter(.cookie(name: "my_param", schema: .string))) } func test_failedExternalReferenceLookup() { @@ -91,7 +210,7 @@ final class ComponentsTests: XCTestCase { } func test_lookupEachType() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "one": .string ], @@ -184,7 +303,10 @@ final class ComponentsTests: XCTestCase { "hello": .boolean ], links: [ - "linky": .init(operationId: "op 1") + "linky": .link(.init(operationId: "op 1")), + "linky_ref": .reference(.component(named: "linky")), + "cycle_start": .reference(.component(named: "cycle_end")), + "cycle_end": .reference(.component(named: "cycle_start")) ] ) @@ -214,6 +336,20 @@ final class ComponentsTests: XCTestCase { XCTAssertEqual((error as? OpenAPI.Components.ReferenceError)?.description, "Failed to look up a JSON Reference. 'hello' was not found in links.") } + let link2: Either, OpenAPI.Link> = .reference(.component(named: "linky")) + + XCTAssertEqual(try components.lookup(link2), .init(operationId: "op 1")) + + let link3: Either, OpenAPI.Link> = .reference(.component(named: "linky_ref")) + + XCTAssertEqual(try components.lookup(link3), .init(operationId: "op 1")) + + let link4: Either, OpenAPI.Link> = .reference(.component(named: "cycle_start")) + + XCTAssertThrowsError(try components.lookup(link4)) { error in + XCTAssertEqual((error as? OpenAPI.Components.ReferenceCycleError)?.description, "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at \'#/components/links/cycle_start\'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case.") + } + let reference1: JSONReference = .component(named: "hello") let resolvedSchema2 = try components.lookup(reference1) @@ -276,7 +412,7 @@ extension ComponentsTests { } func test_maximal_encode() throws { - let t1 = OpenAPI.Components( + let t1 = OpenAPI.Components.direct( schemas: [ "one": .string ], @@ -498,7 +634,7 @@ extension ComponentsTests { XCTAssertEqual( decoded, - OpenAPI.Components( + OpenAPI.Components.direct( schemas: [ "one": .string ], diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index e3238b256..bf4ae9bb2 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -16,7 +16,7 @@ final class DereferencedContentTests: XCTestCase { } func test_oneExampleReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: ["test": .init(value: .init("hello world"))] ) let t1 = try OpenAPI.Content( @@ -31,7 +31,7 @@ final class DereferencedContentTests: XCTestCase { } func test_multipleExamplesReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: [ "test1": .init(value: .init("hello world")), "test2": .init(value: .a(URL(string: "http://website.com")!)) @@ -130,7 +130,7 @@ final class DereferencedContentTests: XCTestCase { } func test_referencedHeaderInEncoding() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( headers: [ "test": OpenAPI.Header(schema: .string) ] diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index 678223292..777b51e50 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -64,7 +64,7 @@ final class DereferencedDocumentTests: XCTestCase { } func test_noSecurityReferencedResponseInPath() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "success") ] @@ -92,7 +92,7 @@ final class DereferencedDocumentTests: XCTestCase { } func test_securityAndReferencedResponseInPath() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "success") ], diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index ce7d142e1..18d493db8 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -827,7 +827,7 @@ extension DocumentTests { info: .init(title: "API", version: "1.0"), servers: [], paths: [:], - components: .init( + components: .direct( securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] ), security: [[.component( named: "security"):[]]] @@ -902,7 +902,7 @@ extension DocumentTests { info: .init(title: "API", version: "1.0"), servers: [], paths: [:], - components: .init( + components: .direct( securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] ), security: [[.component( named: "security"):[]]] diff --git a/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift index 76aebf84b..06b538539 100644 --- a/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift @@ -29,7 +29,7 @@ final class ResolvedDocumentTests: XCTestCase { } func test_documentWithSecurity() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: [ "test": .apiKey(name: "api-key", location: .cookie) ] diff --git a/Tests/OpenAPIKitTests/EaseOfUseTests.swift b/Tests/OpenAPIKitTests/EaseOfUseTests.swift index d8ecabe81..b6e59547b 100644 --- a/Tests/OpenAPIKitTests/EaseOfUseTests.swift +++ b/Tests/OpenAPIKitTests/EaseOfUseTests.swift @@ -110,7 +110,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { ) ) ], - components: .init( + components: .direct( schemas: [ "string_schema": .string ], @@ -243,7 +243,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { post: testCREATE_endpoint ) - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "string_schema": .string ], @@ -343,7 +343,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { } func test_securityRequirements() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: [ "basic_auth": .init( type: .http(scheme: "basic", bearerFormat: nil), @@ -491,7 +491,7 @@ fileprivate let testWidgetSchema = JSONSchema.object( ] ) -fileprivate let testComponents = OpenAPI.Components( +fileprivate let testComponents = OpenAPI.Components.direct( schemas: [ "testWidgetSchema": testWidgetSchema ], diff --git a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift index b192b1bbe..63110fe53 100644 --- a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift +++ b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift @@ -76,7 +76,7 @@ final class OpenAPIReferenceTests: XCTestCase { } func test_summaryAndDescriptionOverrides() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "hello": .string(description: "description") ], diff --git a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift index 1409cfa74..cc4cbc2c9 100644 --- a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift @@ -41,7 +41,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_parameterReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( parameters: [ "test": .header( name: "test", @@ -77,7 +77,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_requestReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( requestBodies: [ "test": OpenAPI.Request(content: [.json: .init(schema: .string)]) ] @@ -109,7 +109,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_responseReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "test") ] @@ -139,7 +139,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_securityReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: ["requirement": .apiKey(name: "Api-Key", location: .header)] ) let t1 = try OpenAPI.Operation( @@ -163,7 +163,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_dereferencedCallback() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( callbacks: [ "callback": [ OpenAPI.CallbackURL(rawValue: "{$url}")!: .pathItem( diff --git a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift index dcac3c9bd..15e32f4fc 100644 --- a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift +++ b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift @@ -302,7 +302,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -357,7 +357,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -411,7 +411,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -510,7 +510,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift index 5b6f7e85d..f7e07a8c0 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift @@ -22,7 +22,7 @@ final class DereferencedSchemaContextTests: XCTestCase { } func test_oneExampleReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: ["test": .init(value: .init("hello world"))] ) let t1 = try OpenAPI.Parameter.SchemaContext( @@ -38,7 +38,7 @@ final class DereferencedSchemaContextTests: XCTestCase { } func test_multipleExamplesReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: [ "test1": .init(value: .init("hello world")), "test2": .init(value: .a(URL(string: "http://website.com")!)) diff --git a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift index 3a48d3f33..465d69d59 100644 --- a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift @@ -65,7 +65,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_referencedParameter() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( parameters: [ "test": .init(name: "param", context: .header(schema: .string)) ] @@ -100,7 +100,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_referencedOperations() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -153,7 +153,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedGetResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "put": .init(description: "put resp"), "post": .init(description: "post resp"), @@ -181,7 +181,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedPutResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "post": .init(description: "post resp"), @@ -209,7 +209,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedPostResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -237,7 +237,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedDeleteResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -265,7 +265,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedOptionsResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -293,7 +293,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedHeadResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -321,7 +321,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedPatchResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -349,7 +349,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedTraceResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -377,7 +377,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedQueryResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -405,7 +405,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_missingReferencedAdditionalOperationResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), diff --git a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift index a712027f8..8d15a86ff 100644 --- a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift @@ -36,7 +36,7 @@ final class DereferencedResponseTests: XCTestCase { } func test_referencedHeader() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( headers: [ "test": .init(schema: .string) ] @@ -94,7 +94,7 @@ final class DereferencedResponseTests: XCTestCase { } func test_referencedLink() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( links: [ "link1": .init(operationId: "linka") ] diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index 7126bb331..21502fbeb 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -508,7 +508,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { XCTAssertThrowsError(try JSONSchema.reference(.component(named: "test")).dereferenced(in: components)) { error in XCTAssertEqual( String(describing: error), - "Encountered a JSON Schema $ref cycle that prevents fully dereferencing document at '#/components/schemas/test'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy." + "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at '#/components/schemas/test'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case." ) } } diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 57bf0d092..0f9bac4fa 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -837,7 +837,7 @@ final class BuiltinValidationTests: XCTestCase { "/world": .reference(.component(named: "path1")), "/external": .reference(.external(URL(string: "https://other-world.com")!)) ], - components: .init( + components: .direct( schemas: [ "schema1": .object ], @@ -909,7 +909,7 @@ final class BuiltinValidationTests: XCTestCase { ) ) ], - components: .init( + components: .direct( links: [ "testLink": link ] @@ -936,7 +936,7 @@ final class BuiltinValidationTests: XCTestCase { ) ) ], - components: .init( + components: .direct( links: [ "testLink": link ] diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index 61ec37a7a..ae9704fb7 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -294,7 +294,7 @@ final class ValidationConvenienceTests: XCTestCase { ] ) ], - components: .init( + components: .direct( parameters: [ "test1": .init(name: "test", context: .header(content: [:])), "test2": .init(name: "test2", context: .query(content: [:])) @@ -336,7 +336,7 @@ final class ValidationConvenienceTests: XCTestCase { ] ) ], - components: .init( + components: .direct( parameters: [ "test1": .init(name: "test", context: .header(content: [:])), "test2": .init(name: "test2", context: .query(content: [:])) From 77bdcd253c4288e2b72877a6bc8c40dcce946c75 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 18 Nov 2025 08:26:47 -0600 Subject: [PATCH 3/4] fix README support table typos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b1d83ca0a..9921233c5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ OpenAPIKit follows semantic versioning despite the fact that the OpenAPI specifi |------------|-------|--------------------|-----------------------------------|--------------| | v3.x | 5.1+ | ✅ | | | | v4.x | 5.8+ | ✅ | ✅ | | -| v4.x | 5.8+ | ✅ | ✅ | ✅ | +| v5.x | 5.10+ | ✅ | ✅ | ✅ | - [Usage](#usage) - [Migration](#migration) From 99831b8daac0883437d1ed2ba33cdc50caffe484 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 18 Nov 2025 08:42:35 -0600 Subject: [PATCH 4/4] Add information on Components Object breaking changes to the migration guide --- .../migration_guides/v5_migration_guide.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 0a94ebf12..096fac59d 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -166,6 +166,55 @@ specification) in this section. The Response Object `description` field is not optional so code may need to change to account for it possibly being `nil`. +### Components Object +There are changes for the `OpenAPIKit30` module (OAS 3.0.x specification) in +this section. + +Entries in the Components Object's `responses`, `parameters`, `examples`, +`requestBodies`, `headers`, `securitySchemes`, `links`, and `callbacks` +dictionaries have all gained support for references. Note that `pathItems` and +`schemas` still do not support references (per the specification), though +`schemas` can be JSON references by their very nature already. + +This change fixes a gap in OpenAPIKit's ability to represent valid documents. + +If you are using subscript access or `lookup()` functions to retrieve entries +from the Components Object, you do _not_ need to change that code. These +functions have learned how to follow references they encounter until they land +on the type of entity being looked up. If you want the behavior of just +doing a regular lookup and passing the result back even if it is a reference, +you can use the new `lookupOnce()` function. The existing `lookup()` functions +can now throw an error they would never throw before: `ReferenceCycleError`. + +Error message phrasing has changed subtly which is unlikely to cause problems +but if you have tests that compare exact error messages then you may need to +update the test expectations. + +If you construct `Components` in-code then you have two options. You can swap +out existing calls to the `Components` `init()` initializer with calls to the +new `Components.direct()` convenience constructor or you can nest each component +entry in an `Either` like follows: +```swift +// BEFORE +Components( + parameters: [ + "param1": .cookie(name: "cookie", schema: .string) + ] +) + +// AFTER +Components( + parameters: [ + "param1": .parameter(.cookie(name: "cookie", schema: .string)) + ] +) +``` + +If your code uses the `static` `openAPIComponentsKeyPath` variable on types that +can be found in the Components Object (likely very uncommon), you will now need +to handle two possibilities: the key path either refers to an object (of generic +type `T`) or it refers to an `Either, T>`. + ### Errors Some error messages have been tweaked in small ways. If you match on the string descriptions of any OpenAPIKit errors, you may need to update the