From cc44467e220228ba7e75207ebb0ff1ee1cdeb005 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 19 Sep 2025 21:06:51 -0500 Subject: [PATCH 01/73] move OAS 3.1.2 support to the base version enum --- Sources/OpenAPIKit/Document/Document.swift | 3 +++ Tests/OpenAPIKitTests/Document/DocumentTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index 5126646e8..f7a3bde16 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -433,12 +433,14 @@ extension OpenAPI.Document { public enum Version: RawRepresentable, Equatable, Codable, Sendable { case v3_1_0 case v3_1_1 + case v3_1_2 case v3_1_x(x: Int) public init?(rawValue: String) { switch rawValue { case "3.1.0": self = .v3_1_0 case "3.1.1": self = .v3_1_1 + case "3.1.2": self = .v3_1_2 default: let components = rawValue.split(separator: ".") guard components.count == 3 else { @@ -464,6 +466,7 @@ extension OpenAPI.Document { switch self { case .v3_1_0: return "3.1.0" case .v3_1_1: return "3.1.1" + case .v3_1_2: return "3.1.2" case .v3_1_x(x: let x): return "3.1.\(x)" } } diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 1c65f6c9c..cec87dd52 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -48,7 +48,7 @@ final class DocumentTests: XCTestCase { let t2 = OpenAPI.Document.Version.v3_1_1 XCTAssertEqual(t2.rawValue, "3.1.1") - let t3 = OpenAPI.Document.Version.v3_1_x(x: 2) + let t3 = OpenAPI.Document.Version.v3_1_2 XCTAssertEqual(t3.rawValue, "3.1.2") let t4 = OpenAPI.Document.Version.v3_1_x(x: 8) @@ -61,7 +61,7 @@ final class DocumentTests: XCTestCase { XCTAssertEqual(t6, .v3_1_1) let t7 = OpenAPI.Document.Version(rawValue: "3.1.2") - XCTAssertEqual(t7, .v3_1_x(x: 2)) + XCTAssertEqual(t7, .v3_1_2) // not a known version: let t8 = OpenAPI.Document.Version(rawValue: "3.1.8") From f6671fa1598296304bac854f85d5e5442be902f7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 13 Oct 2025 11:15:33 -0500 Subject: [PATCH 02/73] Add support for OAS 3.2.x version handling and the Tag summary field --- Sources/OpenAPIKit/Document/Document.swift | 89 +++++++++++++++++-- .../OpenAPIConditionalWarnings.swift | 55 ++++++++++++ Sources/OpenAPIKit/Tag.swift | 63 ++++++++++++- .../OpenAPIWarning.swift | 1 + .../Document/DocumentTests.swift | 37 ++++++-- Tests/OpenAPIKitTests/TagTests.swift | 50 ++++++++++- 6 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index f7a3bde16..6e8ffdabe 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -430,23 +430,28 @@ extension OpenAPI.Document { /// specification releases a new patch version, OpenAPIKit will see a patch version release /// explicitly supports decoding documents of that new patch version before said version will /// succesfully decode as the `v3_1_x` case. - public enum Version: RawRepresentable, Equatable, Codable, Sendable { + public enum Version: RawRepresentable, Equatable, Comparable, Codable, Sendable { case v3_1_0 case v3_1_1 case v3_1_2 case v3_1_x(x: Int) + case v3_2_0 + case v3_2_x(x: Int) + public init?(rawValue: String) { switch rawValue { case "3.1.0": self = .v3_1_0 case "3.1.1": self = .v3_1_1 case "3.1.2": self = .v3_1_2 + case "3.2.0": self = .v3_2_0 default: let components = rawValue.split(separator: ".") guard components.count == 3 else { return nil } - guard components[0] == "3", components[1] == "1" else { + let minorVersion = components[1] + guard components[0] == "3", (minorVersion == "1" || minorVersion == "2") else { return nil } guard let patchVersion = Int(components[2], radix: 10) else { @@ -455,10 +460,17 @@ extension OpenAPI.Document { // to support newer versions released in the future without a breaking // change to the enumeration, bump the upper limit here to e.g. 2 or 3 // or 6: - guard patchVersion > 1 && patchVersion <= 2 else { - return nil + if minorVersion == "2" { + guard patchVersion > 0 && patchVersion <= 0 else { + return nil + } + self = .v3_2_x(x: patchVersion) + } else { + guard patchVersion > 2 && patchVersion <= 2 else { + return nil + } + self = .v3_1_x(x: patchVersion) } - self = .v3_1_x(x: patchVersion) } } @@ -468,6 +480,73 @@ extension OpenAPI.Document { case .v3_1_1: return "3.1.1" case .v3_1_2: return "3.1.2" case .v3_1_x(x: let x): return "3.1.\(x)" + + case .v3_2_0: return "3.2.0" + case .v3_2_x(x: let x): return "3.2.\(x)" + } + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case .v3_1_0: + switch rhs { + case .v3_1_0: false + case .v3_1_1: true + case .v3_1_2: true + case .v3_1_x(x: let x): 0 < x + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_1: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: true + case .v3_1_x(x: let y): 1 < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_2: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: let y): 2 < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_x(x: let x): + switch rhs { + case .v3_1_0: x < 0 + case .v3_1_1: x < 1 + case .v3_1_2: x < 2 + case .v3_1_x(x: let y): x < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_2_0: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: _): false + case .v3_2_0: false + case .v3_2_x(x: let y): 0 < y + } + + case .v3_2_x(x: let x): + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: _): false + case .v3_2_0: x < 0 + case .v3_2_x(x: let y): x < y + } } } } diff --git a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift new file mode 100644 index 000000000..43a4c55fa --- /dev/null +++ b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift @@ -0,0 +1,55 @@ +public protocol Condition: Equatable, Sendable { + /// Given an entire OpenAPI Document, determine the applicability of the + /// condition. + func applies(to: OpenAPI.Document) -> Bool +} + +public protocol HasConditionalWarnings { + /// Warnings that only apply if the paired condition is met. + /// + /// Among other things, this allows OpenAPIKit to generate a warning in + /// some nested type that only applies if the OpenAPI Standards version of + /// the document is less than a certain version. + var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { get } +} + +extension HasConditionalWarnings { + public func applicableConditionalWarnings(for subject: OpenAPI.Document) -> [OpenAPI.Warning] { + conditionalWarnings.compactMap { (condition, warning) in + guard condition.applies(to: subject) else { return nil } + + return warning + } + } +} + +internal struct DocumentVersionCondition: Sendable, Condition { + enum Comparator: Sendable { + case lessThan + case equal + case greaterThan + } + + let version: OpenAPI.Document.Version + let comparator: Comparator + + func applies(to document: OpenAPI.Document) -> Bool { + switch comparator { + case .lessThan: document.openAPIVersion < version + + case .equal: document.openAPIVersion == version + + case .greaterThan: document.openAPIVersion > version + } + } +} + +internal extension OpenAPI.Document { + struct ConditionalWarnings { + static func version(lessThan version: OpenAPI.Document.Version, doesNotSupport subject: String) -> (any Condition, OpenAPI.Warning) { + let warning = OpenAPI.Warning.message("\(subject) is only supported for OpenAPI document versions \(version.rawValue) and later.") + + return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) + } + } +} diff --git a/Sources/OpenAPIKit/Tag.swift b/Sources/OpenAPIKit/Tag.swift index 47393cecb..c1d473910 100644 --- a/Sources/OpenAPIKit/Tag.swift +++ b/Sources/OpenAPIKit/Tag.swift @@ -11,8 +11,10 @@ extension OpenAPI { /// OpenAPI Spec "Tag Object" /// /// See [OpenAPI Tag Object](https://spec.openapis.org/oas/v3.1.1.html#tag-object). - public struct Tag: Equatable, CodableVendorExtendable, Sendable { + public struct Tag: HasConditionalWarnings, CodableVendorExtendable, Sendable { public let name: String + /// Summary of the tag. Available for OAS 3.2.0 and greater. + public let summary: String? public let description: String? public let externalDocs: ExternalDocumentation? @@ -23,33 +25,73 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, Warning)] + public init( name: String, + summary: String? = nil, description: String? = nil, externalDocs: ExternalDocumentation? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name + self.summary = summary self.description = description self.externalDocs = externalDocs self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Tag \(fieldName) field" + ) + } +} + +extension OpenAPI.Tag: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.name == rhs.name + && lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.externalDocs == rhs.externalDocs + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + extension OpenAPI.Tag: ExpressibleByStringLiteral { public init(stringLiteral: String) { self.init(name: stringLiteral) } } -// MARK: - Describable +// MARK: - Describable & Summarizable -extension OpenAPI.Tag : OpenAPIDescribable { +extension OpenAPI.Tag : OpenAPISummarizable { public func overriddenNonNil(description: String?) -> OpenAPI.Tag { guard let description = description else { return self } return OpenAPI.Tag( name: name, + summary: summary, + description: description, + externalDocs: externalDocs, + vendorExtensions: vendorExtensions + ) + } + + public func overriddenNonNil(summary: String?) -> OpenAPI.Tag { + guard let summary = summary else { return self } + return OpenAPI.Tag( + name: name, + summary: summary, description: description, externalDocs: externalDocs, vendorExtensions: vendorExtensions @@ -65,6 +107,8 @@ extension OpenAPI.Tag: Encodable { try container.encode(name, forKey: .name) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(externalDocs, forKey: .externalDocs) @@ -81,17 +125,25 @@ extension OpenAPI.Tag: Decodable { name = try container.decode(String.self, forKey: .name) + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDocumentation.self, forKey: .externalDocs) vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } extension OpenAPI.Tag { internal enum CodingKeys: ExtendableCodingKey { case name + case summary case description case externalDocs case extended(String) @@ -99,6 +151,7 @@ extension OpenAPI.Tag { static var allBuiltinKeys: [CodingKeys] { return [ .name, + .summary, .description, .externalDocs ] @@ -112,6 +165,8 @@ extension OpenAPI.Tag { switch stringValue { case "name": self = .name + case "summary": + self = .summary case "description": self = .description case "externalDocs": @@ -125,6 +180,8 @@ extension OpenAPI.Tag { switch self { case .name: return "name" + case .summary: + return "summary" case .description: return "description" case .externalDocs: diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift index 21aa92406..9ee5df67f 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift @@ -65,5 +65,6 @@ extension Warning: CustomStringConvertible { } public protocol HasWarnings { + /// Warnings generated while decoding an OpenAPI type. var warnings: [Warning] { get } } diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index cec87dd52..88e462829 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -54,18 +54,39 @@ final class DocumentTests: XCTestCase { let t4 = OpenAPI.Document.Version.v3_1_x(x: 8) XCTAssertEqual(t4.rawValue, "3.1.8") - let t5 = OpenAPI.Document.Version(rawValue: "3.1.0") - XCTAssertEqual(t5, .v3_1_0) + let t5 = OpenAPI.Document.Version.v3_2_0 + XCTAssertEqual(t5.rawValue, "3.2.0") - let t6 = OpenAPI.Document.Version(rawValue: "3.1.1") - XCTAssertEqual(t6, .v3_1_1) + let t6 = OpenAPI.Document.Version(rawValue: "3.1.0") + XCTAssertEqual(t6, .v3_1_0) - let t7 = OpenAPI.Document.Version(rawValue: "3.1.2") - XCTAssertEqual(t7, .v3_1_2) + let t7 = OpenAPI.Document.Version(rawValue: "3.1.1") + XCTAssertEqual(t7, .v3_1_1) + + let t8 = OpenAPI.Document.Version(rawValue: "3.1.2") + XCTAssertEqual(t8, .v3_1_2) // not a known version: - let t8 = OpenAPI.Document.Version(rawValue: "3.1.8") - XCTAssertNil(t8) + let t9 = OpenAPI.Document.Version(rawValue: "3.1.8") + XCTAssertNil(t9) + + let t10 = OpenAPI.Document.Version(rawValue: "3.2.8") + XCTAssertNil(t10) + } + + func test_compareOASVersions() { + let versions: [OpenAPI.Document.Version] = [ + .v3_1_0, + .v3_1_1, + .v3_1_2, + .v3_2_0 + ] + + for v1Idx in 0...(versions.count - 2) { + for v2Idx in (v1Idx + 1)...(versions.count - 1) { + XCTAssert(versions[v1Idx] < versions[v2Idx]) + } + } } func test_getRoutes() { diff --git a/Tests/OpenAPIKitTests/TagTests.swift b/Tests/OpenAPIKitTests/TagTests.swift index 52251a3de..ce44c935c 100644 --- a/Tests/OpenAPIKitTests/TagTests.swift +++ b/Tests/OpenAPIKitTests/TagTests.swift @@ -11,12 +11,16 @@ import OpenAPIKit final class TagTests: XCTestCase { func test_init() { let t1 = OpenAPI.Tag(name: "hello") + XCTAssertNil(t1.summary) XCTAssertNil(t1.description) XCTAssertNil(t1.externalDocs) + XCTAssertEqual(t1.conditionalWarnings.count, 0) - let t2 = OpenAPI.Tag(name: "hello", description: "world") + let t2 = OpenAPI.Tag(name: "hello", summary: "hi", description: "world") + XCTAssertEqual(t2.summary, "hi") XCTAssertEqual(t2.description, "world") XCTAssertNil(t2.externalDocs) + XCTAssertEqual(t2.conditionalWarnings.count, 1) let t3 = OpenAPI.Tag( name: "hello", @@ -28,10 +32,12 @@ final class TagTests: XCTestCase { let t4 = OpenAPI.Tag( name: "tag", + summary: "first", description: "orig" ).overriddenNonNil(description: "new") - .overriddenNonNil(summary: "no-op") + .overriddenNonNil(summary: "cool") .overriddenNonNil(description: nil) // no effect + XCTAssertEqual(t4.summary, "cool") XCTAssertEqual(t4.description, "new") } } @@ -63,6 +69,40 @@ extension TagTests { let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) XCTAssertEqual(tag, OpenAPI.Tag(name: "hello")) + XCTAssertEqual(tag.conditionalWarnings.count, 0) + } + + func test_nameAndSummary_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + summary: "world" + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "name" : "hello", + "summary" : "world" + } + """ + ) + } + + func test_nameAndSummary_decode() throws { + let tagData = + """ + { + "name": "hello", + "summary": "world" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", summary: "world")) + XCTAssertEqual(tag.conditionalWarnings.count, 1) } func test_nameAndDescription_encode() throws { @@ -95,11 +135,13 @@ extension TagTests { let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", description: "world")) + XCTAssertEqual(tag.conditionalWarnings.count, 0) } func test_allFields_encode() throws { let tag = OpenAPI.Tag( name: "hello", + summary: "sum", description: "world", externalDocs: .init( url: URL(string: "http://google.com")! @@ -117,6 +159,7 @@ extension TagTests { "url" : "http:\\/\\/google.com" }, "name" : "hello", + "summary" : "sum", "x-specialFeature" : false } """ @@ -128,6 +171,7 @@ extension TagTests { """ { "name": "hello", + "summary": "sum", "description": "world", "externalDocs": { "url": "http://google.com" @@ -142,10 +186,12 @@ extension TagTests { tag, OpenAPI.Tag( name: "hello", + summary: "sum", description: "world", externalDocs: .init(url: URL(string: "http://google.com")!), vendorExtensions: ["x-specialFeature": false] ) ) + XCTAssertEqual(tag.conditionalWarnings.count, 1) } } From eb8cf763f960fab6ac64087b86d5e818a5f1c698 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 13 Oct 2025 12:00:39 -0500 Subject: [PATCH 03/73] Add conditional warnings to validator collection. Test the first such warning implemented. --- Sources/OpenAPIKit/Validator/Validator.swift | 8 ++- .../Validator/ValidatorTests.swift | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 21373a22d..49e1b8fdc 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -545,9 +545,15 @@ extension _Validator: SingleValueEncodingContainer { fileprivate func collectWarnings(from value: Encodable, atKey key: CodingKey? = nil) { let pathTail = key.map { [$0] } ?? [] + var localWarnings = [Warning]() if let warnable = value as? HasWarnings { - warnings += warnable.warnings.map(contextualize(at: codingPath + pathTail)) + localWarnings += warnable.warnings } + if let conditionalWarnable = value as? HasConditionalWarnings { + localWarnings += conditionalWarnable.applicableConditionalWarnings(for: document) + } + + warnings += localWarnings.map(contextualize(at: codingPath + pathTail)) } } diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index e5ed9f59f..1864058b2 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1489,4 +1489,63 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual(errors?.values.first?.codingPathString, ".paths[\'/test\'].get.responses.200.content") } } + + func test_collectsConditionalTagWarningNotStrict() throws { + let docData = """ + { + "info": {"title": "test", "version": "1.0"}, + "openapi": "3.1.0", + "tags": [ {"name": "hi", "summary": "sum"} ] + } + """.data(using: .utf8)! + + let doc = try orderUnstableDecode(OpenAPI.Document.self, from: docData) + + XCTAssertEqual( + doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." + ) + + let warnings = try doc.validate(strict: false) + + XCTAssertEqual(warnings.count, 1) + XCTAssertEqual( + warnings.first?.localizedDescription, + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later.." + ) + XCTAssertEqual(warnings.first?.codingPathString, ".tags[0]") + + // now test that the warning does not apply for v3.2.0 and above + var doc2 = doc + doc2.openAPIVersion = .v3_2_0 + + try XCTAssertEqual(doc2.validate(strict: false).count, 0) + } + + func test_collectsConditionalTagWarningStrict() throws { + let docData = """ + { + "info": {"title": "test", "version": "1.0"}, + "openapi": "3.1.0", + "tags": [ {"name": "hi", "summary": "sum"} ] + } + """.data(using: .utf8)! + + let doc = try orderUnstableDecode(OpenAPI.Document.self, from: docData) + + XCTAssertEqual( + doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." + ) + + XCTAssertThrowsError(try doc.validate(strict: true)) { error in + let errors = error as? ValidationErrorCollection + XCTAssertEqual(errors?.values.count, 1) + XCTAssertEqual( + errors?.localizedDescription, + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later.. at path: .tags[0]" + ) + XCTAssertEqual(errors?.values.first?.codingPathString, ".tags[0]") + } + } } From fd04b6d7e53b62c722a542139c5ab4132e6b6a5f Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 19 Oct 2025 16:02:58 -0500 Subject: [PATCH 04/73] clean up errors that have always had extra punctuation --- Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift | 2 +- .../OpenAPIKit/Schema Object/JSONSchemaContext.swift | 2 +- Sources/OpenAPIKit/Validator/Validation.swift | 6 ++++-- .../SchemaErrorTests.swift | 2 +- Tests/OpenAPIKitTests/Validator/ValidatorTests.swift | 10 +++++----- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift index 43a4c55fa..ed4092da2 100644 --- a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift +++ b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift @@ -47,7 +47,7 @@ internal struct DocumentVersionCondition: Sendable, Condition { internal extension OpenAPI.Document { struct ConditionalWarnings { static func version(lessThan version: OpenAPI.Document.Version, doesNotSupport subject: String) -> (any Condition, OpenAPI.Warning) { - let warning = OpenAPI.Warning.message("\(subject) is only supported for OpenAPI document versions \(version.rawValue) and later.") + let warning = OpenAPI.Warning.message("\(subject) is only supported for OpenAPI document versions \(version.rawValue) and later") return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) } diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index 443a15b42..b340f8557 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -1042,7 +1042,7 @@ extension JSONSchema.CoreContext: Decodable { .underlyingError( GenericError( subjectName: "OpenAPI Schema", - details: "Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: [\"null\", ...]'.", + details: "Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: [\"null\", ...]'", codingPath: container.codingPath ) ) diff --git a/Sources/OpenAPIKit/Validator/Validation.swift b/Sources/OpenAPIKit/Validator/Validation.swift index 3e9851d42..b60d88f6f 100644 --- a/Sources/OpenAPIKit/Validator/Validation.swift +++ b/Sources/OpenAPIKit/Validator/Validation.swift @@ -128,10 +128,12 @@ public struct ValidationError: Swift.Error, CustomStringConvertible, PathContext public var localizedDescription: String { description } public var description: String { + let reasonStr: any StringProtocol = + reason.last == "." ? reason.dropLast() : reason guard !codingPath.isEmpty else { - return "\(reason) at root of document" + return "\(reasonStr) at root of document" } - return "\(reason) at path: \(codingPath.stringValue)" + return "\(reasonStr) at path: \(codingPath.stringValue)" } } diff --git a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift index deeb1f6cb..4aaca5ff4 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift @@ -76,7 +76,7 @@ final class SchemaErrorTests: XCTestCase { XCTAssertEqual(openAPIError.localizedDescription, """ - Problem encountered when parsing `OpenAPI Schema`: Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: ["null", ...]'.. at path: .paths['/hello/world'].get.responses.200.content['application/json'].schema + Problem encountered when parsing `OpenAPI Schema`: Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: ["null", ...]' at path: .paths['/hello/world'].get.responses.200.content['application/json'].schema """) XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index 1864058b2..2278b8974 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1484,7 +1484,7 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual(errors?.values.count, 1) XCTAssertEqual( errors?.localizedDescription, - "Problem encountered when parsing ``: \'gzip\' could not be parsed as a Content Type. Content Types should have the format \'/\'. at path: .paths[\'/test\'].get.responses.200.content" + "Problem encountered when parsing ``: \'gzip\' could not be parsed as a Content Type. Content Types should have the format \'/\' at path: .paths[\'/test\'].get.responses.200.content" ) XCTAssertEqual(errors?.values.first?.codingPathString, ".paths[\'/test\'].get.responses.200.content") } @@ -1503,7 +1503,7 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual( doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, - "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later" ) let warnings = try doc.validate(strict: false) @@ -1511,7 +1511,7 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual(warnings.count, 1) XCTAssertEqual( warnings.first?.localizedDescription, - "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later.." + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." ) XCTAssertEqual(warnings.first?.codingPathString, ".tags[0]") @@ -1535,7 +1535,7 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual( doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, - "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later" ) XCTAssertThrowsError(try doc.validate(strict: true)) { error in @@ -1543,7 +1543,7 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual(errors?.values.count, 1) XCTAssertEqual( errors?.localizedDescription, - "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later.. at path: .tags[0]" + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later at path: .tags[0]" ) XCTAssertEqual(errors?.values.first?.codingPathString, ".tags[0]") } From c7a722073b40a31407af44dcc0d6c86d65faf491 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 19 Oct 2025 16:24:35 -0500 Subject: [PATCH 05/73] add Tag support for the parent field --- Sources/OpenAPIKit/Tag.swift | 27 +++++++++++-- Sources/OpenAPIKitCompat/Compat30To31.swift | 2 + Tests/OpenAPIKitTests/TagTests.swift | 45 ++++++++++++++++++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIKit/Tag.swift b/Sources/OpenAPIKit/Tag.swift index c1d473910..a61dc90a4 100644 --- a/Sources/OpenAPIKit/Tag.swift +++ b/Sources/OpenAPIKit/Tag.swift @@ -17,6 +17,8 @@ extension OpenAPI { public let summary: String? public let description: String? public let externalDocs: ExternalDocumentation? + /// The tag this tag is nested under. + public let parent: String? /// Dictionary of vendor extensions. /// @@ -32,17 +34,21 @@ extension OpenAPI { summary: String? = nil, description: String? = nil, externalDocs: ExternalDocumentation? = nil, + parent: String? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name self.summary = summary self.description = description self.externalDocs = externalDocs + self.parent = parent self.vendorExtensions = vendorExtensions self.conditionalWarnings = [ // If summary is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If parent is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0) ].compactMap { $0 } } } @@ -63,6 +69,7 @@ extension OpenAPI.Tag: Equatable { && lhs.summary == rhs.summary && lhs.description == rhs.description && lhs.externalDocs == rhs.externalDocs + && lhs.parent == rhs.parent && lhs.vendorExtensions == rhs.vendorExtensions } } @@ -83,6 +90,7 @@ extension OpenAPI.Tag : OpenAPISummarizable { summary: summary, description: description, externalDocs: externalDocs, + parent: parent, vendorExtensions: vendorExtensions ) } @@ -94,6 +102,7 @@ extension OpenAPI.Tag : OpenAPISummarizable { summary: summary, description: description, externalDocs: externalDocs, + parent: parent, vendorExtensions: vendorExtensions ) } @@ -113,6 +122,8 @@ extension OpenAPI.Tag: Encodable { try container.encodeIfPresent(externalDocs, forKey: .externalDocs) + try container.encodeIfPresent(parent, forKey: .parent) + if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) } @@ -131,11 +142,15 @@ extension OpenAPI.Tag: Decodable { externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDocumentation.self, forKey: .externalDocs) + parent = try container.decodeIfPresent(String.self, forKey: .parent) + vendorExtensions = try Self.extensions(from: decoder) conditionalWarnings = [ // If summary is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If parent is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0) ].compactMap { $0 } } } @@ -146,6 +161,7 @@ extension OpenAPI.Tag { case summary case description case externalDocs + case parent case extended(String) static var allBuiltinKeys: [CodingKeys] { @@ -153,7 +169,8 @@ extension OpenAPI.Tag { .name, .summary, .description, - .externalDocs + .externalDocs, + .parent ] } @@ -171,6 +188,8 @@ extension OpenAPI.Tag { self = .description case "externalDocs": self = .externalDocs + case "parent": + self = .parent default: self = .extendedKey(for: stringValue) } @@ -186,6 +205,8 @@ extension OpenAPI.Tag { return "description" case .externalDocs: return "externalDocs" + case .parent: + return "parent" case .extended(let key): return key } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 88192c352..92928524a 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -430,8 +430,10 @@ extension OpenAPIKit30.OpenAPI.Tag: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Tag { OpenAPIKit.OpenAPI.Tag( name: name, + summary: nil, description: description, externalDocs: externalDocs?.to31(), + parent: nil, vendorExtensions: vendorExtensions ) } diff --git a/Tests/OpenAPIKitTests/TagTests.swift b/Tests/OpenAPIKitTests/TagTests.swift index ce44c935c..a5816e907 100644 --- a/Tests/OpenAPIKitTests/TagTests.swift +++ b/Tests/OpenAPIKitTests/TagTests.swift @@ -39,6 +39,12 @@ final class TagTests: XCTestCase { .overriddenNonNil(description: nil) // no effect XCTAssertEqual(t4.summary, "cool") XCTAssertEqual(t4.description, "new") + + let t5 = OpenAPI.Tag( + name: "hello", + parent: "otherTag" + ) + XCTAssertEqual(t5.parent, "otherTag") } } @@ -138,6 +144,39 @@ extension TagTests { XCTAssertEqual(tag.conditionalWarnings.count, 0) } + func test_nameAndParent_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + parent: "otherTag" + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "name" : "hello", + "parent" : "otherTag" + } + """ + ) + } + + func test_nameAndParent_decode() throws { + let tagData = + """ + { + "name": "hello", + "parent": "otherTag" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", parent: "otherTag")) + XCTAssertEqual(tag.conditionalWarnings.count, 1) + } + func test_allFields_encode() throws { let tag = OpenAPI.Tag( name: "hello", @@ -146,6 +185,7 @@ extension TagTests { externalDocs: .init( url: URL(string: "http://google.com")! ), + parent: "otherTag", vendorExtensions: ["x-specialFeature": false] ) let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) @@ -159,6 +199,7 @@ extension TagTests { "url" : "http:\\/\\/google.com" }, "name" : "hello", + "parent" : "otherTag", "summary" : "sum", "x-specialFeature" : false } @@ -176,6 +217,7 @@ extension TagTests { "externalDocs": { "url": "http://google.com" }, + "parent": "otherTag", "x-specialFeature" : false } """.data(using: .utf8)! @@ -189,9 +231,10 @@ extension TagTests { summary: "sum", description: "world", externalDocs: .init(url: URL(string: "http://google.com")!), + parent: "otherTag", vendorExtensions: ["x-specialFeature": false] ) ) - XCTAssertEqual(tag.conditionalWarnings.count, 1) + XCTAssertEqual(tag.conditionalWarnings.count, 2) } } From b0353de0a4211cb4adc0c996f1628947b12621d1 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 19 Oct 2025 16:49:14 -0500 Subject: [PATCH 06/73] Add Tag support for the kind field --- Sources/OpenAPIKit/Tag.swift | 61 ++++++++++++++++++++++++++-- Tests/OpenAPIKitTests/TagTests.swift | 45 +++++++++++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIKit/Tag.swift b/Sources/OpenAPIKit/Tag.swift index a61dc90a4..4a910bb3e 100644 --- a/Sources/OpenAPIKit/Tag.swift +++ b/Sources/OpenAPIKit/Tag.swift @@ -19,6 +19,12 @@ extension OpenAPI { public let externalDocs: ExternalDocumentation? /// The tag this tag is nested under. public let parent: String? + /// A machine-readable string to categorize what sort of tag this is. + /// Any string value can be used, but some common options are provided + /// on OpenAPIKit's `Tag.Kind` type as static properties and more can + /// be found in the public registry: + /// https://spec.openapis.org/registry/tag-kind + public let kind: Kind? /// Dictionary of vendor extensions. /// @@ -35,6 +41,7 @@ extension OpenAPI { description: String? = nil, externalDocs: ExternalDocumentation? = nil, parent: String? = nil, + kind: Kind? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name @@ -42,18 +49,51 @@ extension OpenAPI { self.description = description self.externalDocs = externalDocs self.parent = parent + self.kind = kind self.vendorExtensions = vendorExtensions self.conditionalWarnings = [ // If summary is non-nil, the document must be OAS version 3.2.0 or greater nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), // If parent is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0), + // If kind is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "kind", value: kind, minimumVersion: .v3_2_0) ].compactMap { $0 } } } } +extension OpenAPI.Tag { + public struct Kind : ExpressibleByStringLiteral, Codable, Equatable, Sendable { + public let rawValue: String + + public init(stringLiteral: String) { + self.rawValue = stringLiteral + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(rawValue) + } + } +} + +extension OpenAPI.Tag.Kind { + /// See https://spec.openapis.org/registry/tag-kind/audience.html + public static let audience: OpenAPI.Tag.Kind = "audience" + /// See https://spec.openapis.org/registry/tag-kind/badge.html + public static let badge: OpenAPI.Tag.Kind = "badge" + /// See https://spec.openapis.org/registry/tag-kind/nav.html + public static let nav: OpenAPI.Tag.Kind = "nav" +} + fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { value.map { _ in OpenAPI.Document.ConditionalWarnings.version( @@ -70,6 +110,7 @@ extension OpenAPI.Tag: Equatable { && lhs.description == rhs.description && lhs.externalDocs == rhs.externalDocs && lhs.parent == rhs.parent + && lhs.kind == rhs.kind && lhs.vendorExtensions == rhs.vendorExtensions } } @@ -91,6 +132,7 @@ extension OpenAPI.Tag : OpenAPISummarizable { description: description, externalDocs: externalDocs, parent: parent, + kind: kind, vendorExtensions: vendorExtensions ) } @@ -103,6 +145,7 @@ extension OpenAPI.Tag : OpenAPISummarizable { description: description, externalDocs: externalDocs, parent: parent, + kind: kind, vendorExtensions: vendorExtensions ) } @@ -124,6 +167,8 @@ extension OpenAPI.Tag: Encodable { try container.encodeIfPresent(parent, forKey: .parent) + try container.encodeIfPresent(kind, forKey: .kind) + if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) } @@ -144,13 +189,17 @@ extension OpenAPI.Tag: Decodable { parent = try container.decodeIfPresent(String.self, forKey: .parent) + kind = try container.decodeIfPresent(Kind.self, forKey: .kind) + vendorExtensions = try Self.extensions(from: decoder) conditionalWarnings = [ // If summary is non-nil, the document must be OAS version 3.2.0 or greater nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), // If parent is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0), + // If kind is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "kind", value: kind, minimumVersion: .v3_2_0) ].compactMap { $0 } } } @@ -162,6 +211,7 @@ extension OpenAPI.Tag { case description case externalDocs case parent + case kind case extended(String) static var allBuiltinKeys: [CodingKeys] { @@ -170,7 +220,8 @@ extension OpenAPI.Tag { .summary, .description, .externalDocs, - .parent + .parent, + .kind ] } @@ -190,6 +241,8 @@ extension OpenAPI.Tag { self = .externalDocs case "parent": self = .parent + case "kind": + self = .kind default: self = .extendedKey(for: stringValue) } @@ -207,6 +260,8 @@ extension OpenAPI.Tag { return "externalDocs" case .parent: return "parent" + case .kind: + return "kind" case .extended(let key): return key } diff --git a/Tests/OpenAPIKitTests/TagTests.swift b/Tests/OpenAPIKitTests/TagTests.swift index a5816e907..892aa2951 100644 --- a/Tests/OpenAPIKitTests/TagTests.swift +++ b/Tests/OpenAPIKitTests/TagTests.swift @@ -45,6 +45,12 @@ final class TagTests: XCTestCase { parent: "otherTag" ) XCTAssertEqual(t5.parent, "otherTag") + + let t6 = OpenAPI.Tag( + name: "hello", + kind: .nav + ) + XCTAssertEqual(t6.kind, .nav) } } @@ -177,6 +183,39 @@ extension TagTests { XCTAssertEqual(tag.conditionalWarnings.count, 1) } + func test_nameAndKind_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + kind: .badge + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "kind" : "badge", + "name" : "hello" + } + """ + ) + } + + func test_nameAndKind_decode() throws { + let tagData = + """ + { + "name": "hello", + "kind": "audience" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", kind: .audience)) + XCTAssertEqual(tag.conditionalWarnings.count, 1) + } + func test_allFields_encode() throws { let tag = OpenAPI.Tag( name: "hello", @@ -186,6 +225,7 @@ extension TagTests { url: URL(string: "http://google.com")! ), parent: "otherTag", + kind: "mytag", vendorExtensions: ["x-specialFeature": false] ) let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) @@ -198,6 +238,7 @@ extension TagTests { "externalDocs" : { "url" : "http:\\/\\/google.com" }, + "kind" : "mytag", "name" : "hello", "parent" : "otherTag", "summary" : "sum", @@ -218,6 +259,7 @@ extension TagTests { "url": "http://google.com" }, "parent": "otherTag", + "kind": "mytag", "x-specialFeature" : false } """.data(using: .utf8)! @@ -232,9 +274,10 @@ extension TagTests { description: "world", externalDocs: .init(url: URL(string: "http://google.com")!), parent: "otherTag", + kind: "mytag", vendorExtensions: ["x-specialFeature": false] ) ) - XCTAssertEqual(tag.conditionalWarnings.count, 2) + XCTAssertEqual(tag.conditionalWarnings.count, 3) } } From 2622494b6076197664f66051367cb4f03d8f59d7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 19 Oct 2025 19:48:38 -0500 Subject: [PATCH 07/73] drop Swift support prior to 5.10 --- .github/workflows/tests.yml | 4 ---- README.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cebd42bea..a3fab25c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,10 +13,6 @@ jobs: fail-fast: false matrix: image: - - swift:5.8-focal - - swift:5.8-jammy - - swift:5.9-focal - - swift:5.9-jammy - swift:5.10-focal - swift:5.10-jammy - swift:6.0-focal diff --git a/README.md b/README.md index a7c95c4fb..98278a4f3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![sswg:sandbox|94x20](https://img.shields.io/badge/sswg-sandbox-lightgrey.svg)](https://github.com/swift-server/sswg/blob/master/process/incubation.md#sandbox-level) [![Swift 5.8+](http://img.shields.io/badge/Swift-5.8+-blue.svg)](https://swift.org) +[![sswg:sandbox|94x20](https://img.shields.io/badge/sswg-sandbox-lightgrey.svg)](https://github.com/swift-server/sswg/blob/master/process/incubation.md#sandbox-level) [![Swift 5.10+](http://img.shields.io/badge/Swift-5.10+-blue.svg)](https://swift.org) [![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) ![Tests](https://github.com/mattpolzin/OpenAPIKit/workflows/Tests/badge.svg) From 5ab7759ee0a587a03a92fb29eda84919a8df4558 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 19 Oct 2025 20:12:30 -0500 Subject: [PATCH 08/73] add swift 6.2 CI into the matrix --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3fab25c4..1a41f29e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,6 +21,8 @@ jobs: - swift:6.1-focal - swift:6.1-jammy - swift:6.1-noble + - swift:6.2-jammy + - swift:6.2-noble - swiftlang/swift:nightly-focal - swiftlang/swift:nightly-jammy container: ${{ matrix.image }} From 4067f8306e759463bebdbcad0c18e9c9c94243a1 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 20 Oct 2025 09:02:44 -0500 Subject: [PATCH 09/73] support and prefer new official yaml media type --- Sources/OpenAPIKitCore/Shared/ContentType.swift | 3 ++- Tests/OpenAPIKitCoreTests/ContentTypeTests.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKitCore/Shared/ContentType.swift b/Sources/OpenAPIKitCore/Shared/ContentType.swift index 24880dde4..ab70fa763 100644 --- a/Sources/OpenAPIKitCore/Shared/ContentType.swift +++ b/Sources/OpenAPIKitCore/Shared/ContentType.swift @@ -305,7 +305,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .woff: return "font/woff" case .woff2: return "font/woff2" case .xml: return "application/xml" - case .yaml: return "application/x-yaml" + case .yaml: return "application/yaml" case .zip: return "application/zip" case .anyApplication: return "application/*" @@ -359,6 +359,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "font/woff2": self = .woff2 case "application/xml": self = .xml case "application/x-yaml": self = .yaml + case "application/yaml": self = .yaml case "application/zip": self = .zip case "application/*": self = .anyApplication diff --git a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift index d3ed74883..e4c201153 100644 --- a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift +++ b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift @@ -66,6 +66,17 @@ final class ContentTypeTests: XCTestCase { } } + func test_x_yaml() { + // test that we support old x-yaml type but also prefer new official media type + let type1 = Shared.ContentType.init(rawValue: "application/yaml") + let type2 = Shared.ContentType.init(rawValue: "application/x-yaml") + + XCTAssertEqual(type1?.rawValue, "application/yaml") + XCTAssertEqual(type1, .yaml) + XCTAssertEqual(type2?.rawValue, "application/yaml") + XCTAssertEqual(type2, .yaml) + } + func test_goodParam() { let type = Shared.ContentType.init(rawValue: "text/html; charset=utf8") XCTAssertEqual(type?.warnings.count, 0) From efa684ce73ffea4355c2c7fa5b6fc518fa18726f Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 23 Oct 2025 11:19:43 -0500 Subject: [PATCH 10/73] use swift tools version that aligns with minimum supported swift version --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 5e8bafa02..ce22f3cc3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 5.10 import PackageDescription @@ -6,7 +6,7 @@ let package = Package( name: "OpenAPIKit", platforms: [ .macOS(.v10_15), - .iOS(.v11) + .iOS(.v12) ], products: [ .library( From 74e8bb76a2373d667f0791e66ce3729b6e75d808 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 20 Oct 2025 09:41:15 -0500 Subject: [PATCH 11/73] Add new HTTP QUERY method support --- .../Either/Either+Convenience.swift | 2 + .../Path Item/DereferencedPathItem.swift | 10 ++ Sources/OpenAPIKit/Path Item/PathItem.swift | 27 +++++- .../OpenAPIKit/Path Item/ResolvedRoute.swift | 8 +- .../Path Item/DereferencedPathItem.swift | 2 + Sources/OpenAPIKit30/Path Item/PathItem.swift | 5 + .../Path Item/ResolvedRoute.swift | 2 + .../OpenAPIKitCore/Shared/HttpMethod.swift | 1 + Tests/OpenAPIKitTests/ComponentsTests.swift | 11 ++- .../Document/DocumentTests.swift | 20 +++- .../Path Item/DereferencedPathItemTests.swift | 93 ++++++++++++++----- .../Path Item/PathItemTests.swift | 22 ++++- .../Path Item/ResolvedRouteTests.swift | 8 +- documentation/specification_coverage.md | 1 + 14 files changed, 177 insertions(+), 35 deletions(-) diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index f468f0ca2..7900a13d3 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -169,6 +169,7 @@ extension Either where B == OpenAPI.PathItem { head: OpenAPI.Operation? = nil, patch: OpenAPI.Operation? = nil, trace: OpenAPI.Operation? = nil, + query: OpenAPI.Operation? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self = .b( @@ -185,6 +186,7 @@ extension Either where B == OpenAPI.PathItem { head: head, patch: patch, trace: trace, + query: query, vendorExtensions: vendorExtensions ) ) diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index d9f25538f..09a7edc12 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -34,6 +34,8 @@ public struct DereferencedPathItem: Equatable { public let patch: DereferencedOperation? /// The dereferenced TRACE operation, if defined. public let trace: DereferencedOperation? + /// The dereferenced QUERY operation, if defined. + public let query: DereferencedOperation? public subscript(dynamicMember path: KeyPath) -> T { return underlyingPathItem[keyPath: path] @@ -64,6 +66,7 @@ public struct DereferencedPathItem: Equatable { self.head = try pathItem.head.map { try DereferencedOperation($0, resolvingIn: components, following: references) } self.patch = try pathItem.patch.map { try DereferencedOperation($0, resolvingIn: components, following: references) } self.trace = try pathItem.trace.map { try DereferencedOperation($0, resolvingIn: components, following: references) } + self.query = try pathItem.query.map { try DereferencedOperation($0, resolvingIn: components, following: references) } var pathItem = pathItem if let name { @@ -96,6 +99,8 @@ extension DereferencedPathItem { return self.put case .trace: return self.trace + case .query: + return self.query } } @@ -151,6 +156,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { let oldHead = head let oldPatch = patch let oldTrace = trace + let oldQuery = query async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) // async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) @@ -162,6 +168,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) + async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) var pathItem = self var newComponents = try await c1 @@ -179,6 +186,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { pathItem.head = try await newHead pathItem.patch = try await newPatch pathItem.trace = try await newTrace + pathItem.query = try await newQuery try await newComponents.merge(c3) try await newComponents.merge(c4) @@ -188,6 +196,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newComponents.merge(c8) try await newComponents.merge(c9) try await newComponents.merge(c10) + try await newComponents.merge(c11) try await newMessages += m3 try await newMessages += m4 @@ -197,6 +206,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newMessages += m8 try await newMessages += m9 try await newMessages += m10 + try await newMessages += m11 if let oldServers { async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index a98654074..41d768134 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -8,7 +8,9 @@ import OpenAPIKitCore extension OpenAPI { - /// OpenAPI Spec "Path Item Object" + /// OpenAPI Spec "Path Item Object" (although in the spec the Path Item + /// Object also includes reference support which OpenAPIKit implements via + /// the PathItem.Map type) /// /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.1.1.html#path-item-object). /// @@ -52,6 +54,8 @@ extension OpenAPI { public var patch: Operation? /// The `TRACE` endpoint at this path, if one exists. public var trace: Operation? + /// The `QUERY` endpoint at this path, if one exists. + public var query: Operation? /// Dictionary of vendor extensions. /// @@ -73,6 +77,7 @@ extension OpenAPI { head: Operation? = nil, patch: Operation? = nil, trace: Operation? = nil, + query: Operation? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.summary = summary @@ -88,6 +93,7 @@ extension OpenAPI { self.head = head self.patch = patch self.trace = trace + self.query = query self.vendorExtensions = vendorExtensions } @@ -130,6 +136,11 @@ extension OpenAPI { public mutating func trace(_ op: Operation?) { trace = op } + + /// Set the `QUERY` endpoint operation. + public mutating func query(_ op: Operation?) { + query = op + } } } @@ -164,6 +175,8 @@ extension OpenAPI.PathItem { return self.put case .trace: return self.trace + case .query: + return self.query } } @@ -186,6 +199,8 @@ extension OpenAPI.PathItem { self.put(operation) case .trace: self.trace(operation) + case .query: + self.query(operation) } } @@ -256,6 +271,7 @@ extension OpenAPI.PathItem: Encodable { try container.encodeIfPresent(head, forKey: .head) try container.encodeIfPresent(patch, forKey: .patch) try container.encodeIfPresent(trace, forKey: .trace) + try container.encodeIfPresent(query, forKey: .query) if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) @@ -281,6 +297,7 @@ extension OpenAPI.PathItem: Decodable { head = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .head) patch = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .patch) trace = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .trace) + query = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .query) vendorExtensions = try Self.extensions(from: decoder) } catch let error as DecodingError { @@ -314,6 +331,7 @@ extension OpenAPI.PathItem { case head case patch case trace + case query case extended(String) @@ -331,7 +349,8 @@ extension OpenAPI.PathItem { .options, .head, .patch, - .trace + .trace, + .query ] } @@ -365,6 +384,8 @@ extension OpenAPI.PathItem { self = .patch case "trace": self = .trace + case "query": + self = .query default: self = .extendedKey(for: stringValue) } @@ -396,6 +417,8 @@ extension OpenAPI.PathItem { return "patch" case .trace: return "trace" + case .query: + return "query" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift index ed2a7062c..c15ef5bb9 100644 --- a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift @@ -64,6 +64,8 @@ public struct ResolvedRoute: Equatable { public let patch: ResolvedEndpoint? /// The HTTP `TRACE` endpoint at this route. public let trace: ResolvedEndpoint? + /// The HTTP `QUERY` endpoint at this route. + public let query: ResolvedEndpoint? /// Create a ResolvedRoute. /// @@ -103,6 +105,7 @@ public struct ResolvedRoute: Equatable { self.head = endpoints[.head] self.patch = endpoints[.patch] self.trace = endpoints[.trace] + self.query = endpoints[.query] } /// An array of all endpoints at this route. @@ -115,7 +118,8 @@ public struct ResolvedRoute: Equatable { self.options, self.head, self.patch, - self.trace + self.trace, + self.query ].compactMap { $0 } } @@ -138,6 +142,8 @@ public struct ResolvedRoute: Equatable { return self.put case .trace: return self.trace + case .query: + return self.query } } diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index 542b8aa54..a20db00e5 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -95,6 +95,8 @@ extension DereferencedPathItem { return self.put case .trace: return self.trace + case .query: + return nil } } diff --git a/Sources/OpenAPIKit30/Path Item/PathItem.swift b/Sources/OpenAPIKit30/Path Item/PathItem.swift index 3cbc5abc2..fae4078dd 100644 --- a/Sources/OpenAPIKit30/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/PathItem.swift @@ -164,6 +164,8 @@ extension OpenAPI.PathItem { return self.put case .trace: return self.trace + case .query: + return nil } } @@ -186,6 +188,9 @@ extension OpenAPI.PathItem { self.put(operation) case .trace: self.trace(operation) + case .query: + // not representable + print("The QUERY operation was not directly representable in the OAS standard until version 3.2.0") } } diff --git a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift index ed2a7062c..4f4e27c12 100644 --- a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift @@ -138,6 +138,8 @@ public struct ResolvedRoute: Equatable { return self.put case .trace: return self.trace + case .query: + return nil } } diff --git a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift index 57481265e..356dbd354 100644 --- a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift +++ b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift @@ -21,5 +21,6 @@ extension Shared { case head = "HEAD" case options = "OPTIONS" case trace = "TRACE" + case query = "QUERY" } } diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 00c5b10bd..0cbf875b7 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -581,7 +581,8 @@ extension ComponentsTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) ] ) @@ -614,6 +615,9 @@ extension ComponentsTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -646,6 +650,8 @@ extension ComponentsTests { "put" : { }, "trace" : { + }, + "query" : { } } } @@ -667,7 +673,8 @@ extension ComponentsTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) ] ) diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index d57cbd4aa..166e12879 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -1065,7 +1065,7 @@ extension DocumentTests { func test_webhooks_encode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) let pathItemTest: Either, OpenAPI.PathItem> = .pathItem(pathItem) let document = OpenAPI.Document( @@ -1113,6 +1113,9 @@ extension DocumentTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -1127,7 +1130,7 @@ extension DocumentTests { func test_webhooks_encode_decode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem = OpenAPI.PathItem(get: op, put: op, post: op, options: op, head: op, patch: op, trace: op) + let pathItem = OpenAPI.PathItem(get: op, put: op, post: op, options: op, head: op, patch: op, trace: op, query: op) let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), @@ -1178,6 +1181,8 @@ extension DocumentTests { "put": { }, "trace": { + }, + "query": { } } } @@ -1193,7 +1198,7 @@ extension DocumentTests { servers: [], paths: [:], webhooks: [ - "webhook-test": .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + "webhook-test": .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) ], components: .noComponents, externalDocs: .init(url: URL(string: "http://google.com")!) @@ -1203,7 +1208,7 @@ extension DocumentTests { func test_webhooks_noPaths_encode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) let pathItemTest: Either, OpenAPI.PathItem> = .pathItem(pathItem) let document = OpenAPI.Document( @@ -1251,6 +1256,9 @@ extension DocumentTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -1292,6 +1300,8 @@ extension DocumentTests { "put": { }, "trace": { + }, + "query": { } } } @@ -1307,7 +1317,7 @@ extension DocumentTests { servers: [], paths: [:], webhooks: [ - "webhook-test": .pathItem(.init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op)) + "webhook-test": .pathItem(.init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op)) ], components: .noComponents, externalDocs: .init(url: URL(string: "http://google.com")!) diff --git a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift index c968f1408..1f097ab3f 100644 --- a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift @@ -24,6 +24,7 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertNil(t1[.post]) XCTAssertNil(t1[.put]) XCTAssertNil(t1[.trace]) + XCTAssertNil(t1[.query]) // test dynamic member lookup XCTAssertEqual(t1.summary, "test") @@ -41,10 +42,11 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [:]), head: .init(tags: "head op", responses: [:]), patch: .init(tags: "patch op", responses: [:]), - trace: .init(tags: "trace op", responses: [:]) + trace: .init(tags: "trace op", responses: [:]), + query: .init(tags: "query op", responses: [:]) ).dereferenced(in: .noComponents) - XCTAssertEqual(t1.endpoints.count, 8) + XCTAssertEqual(t1.endpoints.count, 9) XCTAssertEqual(t1.parameters.map { $0.schemaOrContent.schemaValue?.jsonSchema }, [.string]) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -54,6 +56,7 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.post]?.tags, ["post op"]) XCTAssertEqual(t1[.put]?.tags, ["put op"]) XCTAssertEqual(t1[.trace]?.tags, ["trace op"]) + XCTAssertEqual(t1[.query]?.tags, ["query op"]) } func test_referencedParameter() throws { @@ -101,7 +104,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) let t1 = try OpenAPI.PathItem( @@ -112,10 +116,11 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) - XCTAssertEqual(t1.endpoints.count, 8) + XCTAssertEqual(t1.endpoints.count, 9) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.delete]?.responses[status: 200]?.description, "delete resp") XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -132,6 +137,8 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.put]?.responses[status: 200]?.description, "put resp") XCTAssertEqual(t1[.trace]?.tags, ["trace op"]) XCTAssertEqual(t1[.trace]?.responses[status: 200]?.description, "trace resp") + XCTAssertEqual(t1[.query]?.tags, ["query op"]) + XCTAssertEqual(t1[.query]?.responses[status: 200]?.description, "query resp") } func test_missingReferencedGetResp() { @@ -143,7 +150,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -155,7 +163,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -169,7 +178,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -181,7 +191,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -195,7 +206,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -207,7 +219,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -221,7 +234,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -233,7 +247,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -247,7 +262,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -259,7 +275,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -273,7 +290,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -285,7 +303,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -299,7 +318,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "head": .init(description: "head resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -311,7 +331,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } @@ -325,7 +346,36 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "head": .init(description: "head resp"), - "patch": .init(description: "patch resp") + "patch": .init(description: "patch resp"), + "query": .init(description: "query resp") + ] + ) + XCTAssertThrowsError( + try OpenAPI.PathItem( + get: .init(tags: "get op", responses: [200: .reference(.component(named: "get"))]), + put: .init(tags: "put op", responses: [200: .reference(.component(named: "put"))]), + post: .init(tags: "post op", responses: [200: .reference(.component(named: "post"))]), + delete: .init(tags: "delete op", responses: [200: .reference(.component(named: "delete"))]), + options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), + head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), + patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) + ).dereferenced(in: components) + ) + } + + func test_missingReferencedQueryResp() { + let components = OpenAPI.Components( + responses: [ + "get": .init(description: "get resp"), + "put": .init(description: "put resp"), + "post": .init(description: "post resp"), + "delete": .init(description: "delete resp"), + "options": .init(description: "options resp"), + "head": .init(description: "head resp"), + "patch": .init(description: "patch resp"), + "trace": .init(description: "trace resp") ] ) XCTAssertThrowsError( @@ -337,7 +387,8 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } diff --git a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift index 2a4000777..4074021c0 100644 --- a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift @@ -54,7 +54,8 @@ final class PathItemTests: XCTestCase { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) } @@ -71,6 +72,7 @@ final class PathItemTests: XCTestCase { XCTAssertNil(pathItem.head) XCTAssertNil(pathItem.patch) XCTAssertNil(pathItem.trace) + XCTAssertNil(pathItem.query) pathItem.get(op) XCTAssertEqual(pathItem.get, op) @@ -99,6 +101,9 @@ final class PathItemTests: XCTestCase { pathItem.trace(op) XCTAssertEqual(pathItem.trace, op) + pathItem.query(op) + XCTAssertEqual(pathItem.query, op) + // for/set/subscript pathItem = .init() XCTAssertNil(pathItem[.get]) @@ -109,6 +114,7 @@ final class PathItemTests: XCTestCase { XCTAssertNil(pathItem[.head]) XCTAssertNil(pathItem[.patch]) XCTAssertNil(pathItem[.trace]) + XCTAssertNil(pathItem[.query]) pathItem[.get] = op XCTAssertEqual(pathItem.for(.get), op) @@ -133,6 +139,9 @@ final class PathItemTests: XCTestCase { pathItem[.trace] = op XCTAssertEqual(pathItem.for(.trace), op) + + pathItem[.query] = op + XCTAssertEqual(pathItem.for(.query), op) } func test_initializePathItemMap() { @@ -264,7 +273,8 @@ extension PathItemTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) let encodedPathItem = try orderUnstableTestStringFromEncoding(of: pathItem) @@ -293,6 +303,9 @@ extension PathItemTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -321,6 +334,8 @@ extension PathItemTests { "put" : { }, "trace" : { + }, + "query" : { } } """.data(using: .utf8)! @@ -339,7 +354,8 @@ extension PathItemTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) ) } diff --git a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift index 362b72eac..96d4b8214 100644 --- a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift @@ -51,6 +51,10 @@ final class ResolvedRouteTests: XCTestCase { summary: "trace", responses: [200: .response(description: "hello world")] ), + query: .init( + summary: "query", + responses: [200: .response(description: "hello world")] + ), vendorExtensions: [ "test": "route" ] @@ -76,8 +80,9 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.head?.endpointSummary, "head") XCTAssertEqual(routes.first?.patch?.endpointSummary, "patch") XCTAssertEqual(routes.first?.trace?.endpointSummary, "trace") + XCTAssertEqual(routes.first?.query?.endpointSummary, "query") - XCTAssertEqual(routes.first?.endpoints.count, 8) + XCTAssertEqual(routes.first?.endpoints.count, 9) XCTAssertEqual(routes.first?.get, routes.first?[.get]) XCTAssertEqual(routes.first?.put, routes.first?[.put]) @@ -87,6 +92,7 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.head, routes.first?[.head]) XCTAssertEqual(routes.first?.patch, routes.first?[.patch]) XCTAssertEqual(routes.first?.trace, routes.first?[.trace]) + XCTAssertEqual(routes.first?.query, routes.first?[.query]) } func test_pathServersTakePrecedence() throws { diff --git a/documentation/specification_coverage.md b/documentation/specification_coverage.md index e390c7f65..761130a35 100644 --- a/documentation/specification_coverage.md +++ b/documentation/specification_coverage.md @@ -112,6 +112,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] head - [x] patch - [x] trace +- [x] query - [x] servers - [x] parameters - [x] specification extensions (`vendorExtensions`) From da796344b798c2c7767b5af8c22116ee8442c9e7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 20 Oct 2025 11:04:38 -0500 Subject: [PATCH 12/73] Add OAS 3.2.0 warning for PathItems with query endpoints --- Sources/OpenAPIKit/Path Item/PathItem.swift | 46 ++++++++++++++++++++- Sources/OpenAPIKit/Tag.swift | 4 ++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index 41d768134..9f737c51f 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -23,7 +23,7 @@ extension OpenAPI { /// /// You can access an array of equatable `HttpMethod`/`Operation` paris with the /// `endpoints` property. - public struct PathItem: Equatable, CodableVendorExtendable, Sendable { + public struct PathItem: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var summary: String? public var description: String? public var servers: [OpenAPI.Server]? @@ -64,6 +64,12 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// Warnings that apply conditionally depending on the OpenAPI Document + /// the PathItem belongs to. + /// + /// Check these with the `applicableConditionalWarnings(for:)` method. + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( summary: String? = nil, description: String? = nil, @@ -95,6 +101,11 @@ extension OpenAPI { self.trace = trace self.query = query self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If query is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0) + ].compactMap { $0 } } /// Set the `GET` endpoint operation. @@ -144,6 +155,34 @@ extension OpenAPI { } } +extension OpenAPI.PathItem: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.servers == rhs.servers + && lhs.parameters == rhs.parameters + && lhs.get == rhs.get + && lhs.put == rhs.put + && lhs.post == rhs.post + && lhs.delete == rhs.delete + && lhs.options == rhs.options + && lhs.head == rhs.head + && lhs.patch == rhs.patch + && lhs.trace == rhs.trace + && lhs.query == rhs.query + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The PathItem \(fieldName) field" + ) + } +} + extension OpenAPI.PathItem { public typealias Map = OrderedDictionary, OpenAPI.PathItem>> } @@ -300,6 +339,11 @@ extension OpenAPI.PathItem: Decodable { query = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .query) vendorExtensions = try Self.extensions(from: decoder) + + self.conditionalWarnings = [ + // If query is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0) + ].compactMap { $0 } } catch let error as DecodingError { throw OpenAPI.Error.Decoding.Path(error) diff --git a/Sources/OpenAPIKit/Tag.swift b/Sources/OpenAPIKit/Tag.swift index 4a910bb3e..27d9adf92 100644 --- a/Sources/OpenAPIKit/Tag.swift +++ b/Sources/OpenAPIKit/Tag.swift @@ -33,6 +33,10 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// Warnings that apply conditionally depending on the OpenAPI Document + /// the Tag belongs to. + /// + /// Check these with the `applicableConditionalWarnings(for:)` method. public let conditionalWarnings: [(any Condition, Warning)] public init( From 4b75af77627e3c2310d79b4d1c0bf22e30aa0796 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 21 Oct 2025 10:36:03 -0500 Subject: [PATCH 13/73] Add additionalOperations to PathItem type. Expand HttpMethod type to support additional methods not built into library. --- .../Either/Either+Convenience.swift | 2 + .../PathDecodingError.swift | 8 +- .../Path Item/DereferencedPathItem.swift | 52 +++--- Sources/OpenAPIKit/Path Item/PathItem.swift | 145 ++++++++++++----- .../OpenAPIKit/Path Item/ResolvedRoute.swift | 70 ++++---- Sources/OpenAPIKit/_CoreReExport.swift | 1 + Sources/OpenAPIKit30/Document/Document.swift | 2 +- .../Operation/ResolvedEndpoint.swift | 2 +- .../Path Item/DereferencedPathItem.swift | 8 +- Sources/OpenAPIKit30/Path Item/PathItem.swift | 12 +- .../Path Item/ResolvedRoute.swift | 4 +- Sources/OpenAPIKit30/_CoreReExport.swift | 1 + .../DecodingErrorExtensions.swift | 8 + .../OpenAPIKitCore/Shared/HttpMethod.swift | 87 +++++++++- .../Validator/ValidatorTests.swift | 4 +- .../Path Item/DereferencedPathItemTests.swift | 53 +++++- .../Path Item/PathItemTests.swift | 151 +++++++++++++++++- .../Path Item/ResolvedRouteTests.swift | 10 +- .../Validator/ValidatorTests.swift | 4 +- 19 files changed, 501 insertions(+), 123 deletions(-) diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index 7900a13d3..f9cd238dc 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -170,6 +170,7 @@ extension Either where B == OpenAPI.PathItem { patch: OpenAPI.Operation? = nil, trace: OpenAPI.Operation? = nil, query: OpenAPI.Operation? = nil, + additionalOperations: OrderedDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { self = .b( @@ -187,6 +188,7 @@ extension Either where B == OpenAPI.PathItem { patch: patch, trace: trace, query: query, + additionalOperations: additionalOperations, vendorExtensions: vendorExtensions ) ) diff --git a/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift b/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift index 4de8c4e44..30023d0ab 100644 --- a/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift +++ b/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift @@ -104,7 +104,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: DecodingError) { var codingPath = error.codingPathWithoutSubject.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .other(error) @@ -113,7 +113,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: OpenAPI.Error.Decoding.Operation) { var codingPath = error.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .endpoint(error) @@ -122,7 +122,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: GenericError) { var codingPath = error.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .inconsistency(error) @@ -148,7 +148,7 @@ extension OpenAPI.Error.Decoding.Path { // } var codingPath = eitherError.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .neither(eitherError) diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 09a7edc12..cdc11150d 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -37,6 +37,11 @@ public struct DereferencedPathItem: Equatable { /// The dereferenced QUERY operation, if defined. public let query: DereferencedOperation? + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public let additionalOperations: OrderedDictionary + public subscript(dynamicMember path: KeyPath) -> T { return underlyingPathItem[keyPath: path] } @@ -68,6 +73,8 @@ public struct DereferencedPathItem: Equatable { self.trace = try pathItem.trace.map { try DereferencedOperation($0, resolvingIn: components, following: references) } self.query = try pathItem.query.map { try DereferencedOperation($0, resolvingIn: components, following: references) } + self.additionalOperations = try pathItem.additionalOperations.mapValues { try DereferencedOperation($0, resolvingIn: components, following: references) } + var pathItem = pathItem if let name { pathItem.vendorExtensions[OpenAPI.Components.componentNameExtension] = .init(name) @@ -83,24 +90,20 @@ extension DereferencedPathItem { /// Retrieve the operation for the given verb, if one is set for this path. public func `for`(_ verb: OpenAPI.HttpMethod) -> DereferencedOperation? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace - case .query: - return self.query + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + additionalOperations[.other(other)] } } @@ -122,9 +125,11 @@ extension DereferencedPathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in - self.for(method).map { .init(method: method, operation: $0) } + let builtins = OpenAPI.BuiltinHttpMethod.allCases.compactMap { method -> Endpoint? in + self.for(.builtin(method)).map { .init(method: .builtin(method), operation: $0) } } + + return builtins + additionalOperations.map { key, value in .init(method: key, operation: value) } } } @@ -158,6 +163,8 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { let oldTrace = trace let oldQuery = query + let oldAdditionalOperations = additionalOperations + async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) // async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) @@ -170,6 +177,8 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) + async let (newAdditionalOperations, c12, m12) = oldAdditionalOperations.externallyDereferenced(with: loader) + var pathItem = self var newComponents = try await c1 var newMessages = try await m1 @@ -187,6 +196,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { pathItem.patch = try await newPatch pathItem.trace = try await newTrace pathItem.query = try await newQuery + pathItem.additionalOperations = try await newAdditionalOperations try await newComponents.merge(c3) try await newComponents.merge(c4) @@ -197,6 +207,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newComponents.merge(c9) try await newComponents.merge(c10) try await newComponents.merge(c11) + try await newComponents.merge(c12) try await newMessages += m3 try await newMessages += m4 @@ -207,6 +218,7 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newMessages += m9 try await newMessages += m10 try await newMessages += m11 + try await newMessages += m12 if let oldServers { async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index 9f737c51f..dd3445d2a 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -57,6 +57,11 @@ extension OpenAPI { /// The `QUERY` endpoint at this path, if one exists. public var query: Operation? + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public var additionalOperations: OrderedDictionary + /// Dictionary of vendor extensions. /// /// These should be of the form: @@ -84,6 +89,7 @@ extension OpenAPI { patch: Operation? = nil, trace: Operation? = nil, query: Operation? = nil, + additionalOperations: OrderedDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { self.summary = summary @@ -100,11 +106,14 @@ extension OpenAPI { self.patch = patch self.trace = trace self.query = query + self.additionalOperations = additionalOperations self.vendorExtensions = vendorExtensions self.conditionalWarnings = [ // If query is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0), + // If there are additionalOperations defiend, the document must be OAS version 3.2.0 or greater + nonEmptyVersionWarning(fieldName: "additionalOperations", value: additionalOperations, minimumVersion: .v3_2_0) ].compactMap { $0 } } @@ -170,6 +179,7 @@ extension OpenAPI.PathItem: Equatable { && lhs.patch == rhs.patch && lhs.trace == rhs.trace && lhs.query == rhs.query + && lhs.additionalOperations == rhs.additionalOperations && lhs.vendorExtensions == rhs.vendorExtensions } } @@ -183,6 +193,15 @@ fileprivate func nonNilVersionWarning(fieldName: String, value: Subject } } +fileprivate func nonEmptyVersionWarning(fieldName: String, value: OrderedDictionary, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + if value.isEmpty { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The PathItem \(fieldName) map" + ) +} + extension OpenAPI.PathItem { public typealias Map = OrderedDictionary, OpenAPI.PathItem>> } @@ -198,48 +217,49 @@ extension OpenAPI.PathItem { /// Retrieve the operation for the given verb, if one is set for this path. public func `for`(_ verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace - case .query: - return self.query + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + additionalOperations[.other(other)] } } /// Set the operation for the given verb, overwriting any already set operation for the same verb. public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.HttpMethod) { switch verb { - case .delete: - self.delete(operation) - case .get: - self.get(operation) - case .head: - self.head(operation) - case .options: - self.options(operation) - case .patch: - self.patch(operation) - case .post: - self.post(operation) - case .put: - self.put(operation) - case .trace: - self.trace(operation) - case .query: - self.query(operation) + case .builtin(let builtin): + switch builtin { + case .delete: + self.delete(operation) + case .get: + self.get(operation) + case .head: + self.head(operation) + case .options: + self.options(operation) + case .patch: + self.patch(operation) + case .post: + self.post(operation) + case .put: + self.put(operation) + case .trace: + self.trace(operation) + case .query: + self.query(operation) + } + case .other(let other): + self.additionalOperations[.other(other)] = operation } } @@ -264,9 +284,11 @@ extension OpenAPI.PathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in - self.for(method).map { .init(method: method, operation: $0) } + let builtins = OpenAPI.BuiltinHttpMethod.allCases.compactMap { method -> Endpoint? in + self.for(.builtin(method)).map { .init(method: .builtin(method), operation: $0) } } + + return builtins + additionalOperations.map { key, value in .init(method: key, operation: value) } } } @@ -312,6 +334,10 @@ extension OpenAPI.PathItem: Encodable { try container.encodeIfPresent(trace, forKey: .trace) try container.encodeIfPresent(query, forKey: .query) + if !additionalOperations.isEmpty { + try container.encode(additionalOperations, forKey: .additionalOperations) + } + if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) } @@ -338,13 +364,35 @@ extension OpenAPI.PathItem: Decodable { trace = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .trace) query = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .query) + additionalOperations = try container.decodeIfPresent(OrderedDictionary.self, forKey: .additionalOperations) ?? [:] + + let disallowedMethods = builtinHttpMethods(in: additionalOperations) + if !disallowedMethods.isEmpty { + let disallowedMethodsString = disallowedMethods + .map(\.rawValue) + .joined(separator: ", ") + + throw GenericError(subjectName: "additionalOperations", details: "Additional Operations cannot contain operations that can be set directly on the Path Item. Found the following disallowed additional operations: \(disallowedMethodsString)", codingPath: decoder.codingPath, pathIncludesSubject: false) + } + vendorExtensions = try Self.extensions(from: decoder) self.conditionalWarnings = [ // If query is non-nil, the document must be OAS version 3.2.0 or greater - nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0) + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0), + // If there are additionalOperations defiend, the document must be OAS version 3.2.0 or greater + nonEmptyVersionWarning(fieldName: "additionalOperations", value: additionalOperations, minimumVersion: .v3_2_0) ].compactMap { $0 } } catch let error as DecodingError { + if let underlyingError = error.underlyingError as? KeyDecodingError { + throw OpenAPI.Error.Decoding.Path( + GenericError( + subjectName: error.subjectName, + details: underlyingError.localizedDescription, + codingPath: decoder.codingPath + ) + ) + } throw OpenAPI.Error.Decoding.Path(error) } catch let error as GenericError { @@ -360,6 +408,13 @@ extension OpenAPI.PathItem: Decodable { } } +fileprivate func builtinHttpMethods(in map: OrderedDictionary) -> [OpenAPI.HttpMethod] { + map.keys + .filter { + OpenAPI.BuiltinHttpMethod.allCases.map(\.rawValue).contains($0.rawValue.uppercased()) + } +} + extension OpenAPI.PathItem { internal enum CodingKeys: ExtendableCodingKey { case summary @@ -377,6 +432,8 @@ extension OpenAPI.PathItem { case trace case query + case additionalOperations + case extended(String) static var allBuiltinKeys: [CodingKeys] { @@ -394,7 +451,9 @@ extension OpenAPI.PathItem { .head, .patch, .trace, - .query + .query, + + .additionalOperations ] } @@ -430,6 +489,8 @@ extension OpenAPI.PathItem { self = .trace case "query": self = .query + case "additionalOperations": + self = .additionalOperations default: self = .extendedKey(for: stringValue) } @@ -463,6 +524,8 @@ extension OpenAPI.PathItem { return "trace" case .query: return "query" + case .additionalOperations: + return "additionalOperations" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift index c15ef5bb9..ed37d68a5 100644 --- a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift @@ -67,6 +67,11 @@ public struct ResolvedRoute: Equatable { /// The HTTP `QUERY` endpoint at this route. public let query: ResolvedEndpoint? + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public let additionalOperations: OrderedDictionary + /// Create a ResolvedRoute. /// /// `ResolvedRoute` creation is only publicly @@ -85,11 +90,18 @@ public struct ResolvedRoute: Equatable { servers: [OpenAPI.Server], endpoints: [ResolvedEndpoint] ) { - let endpoints = Dictionary( + let builtinEndpoints = Dictionary( endpoints.map { ($0.method, $0) }, uniquingKeysWith: { $1 } ) + let otherEndpoints = endpoints.compactMap { endpoint -> (key: OpenAPI.HttpMethod, value: ResolvedEndpoint)? in + switch endpoint.method { + case .builtin(_): return nil + case .other(_): return (key: endpoint.method, value: endpoint) + } + } + self.summary = summary self.description = description self.vendorExtensions = vendorExtensions @@ -97,20 +109,22 @@ public struct ResolvedRoute: Equatable { self.parameters = parameters self.servers = servers - self.get = endpoints[.get] - self.put = endpoints[.put] - self.post = endpoints[.post] - self.delete = endpoints[.delete] - self.options = endpoints[.options] - self.head = endpoints[.head] - self.patch = endpoints[.patch] - self.trace = endpoints[.trace] - self.query = endpoints[.query] + self.get = builtinEndpoints[.builtin(.get)] + self.put = builtinEndpoints[.builtin(.put)] + self.post = builtinEndpoints[.builtin(.post)] + self.delete = builtinEndpoints[.builtin(.delete)] + self.options = builtinEndpoints[.builtin(.options)] + self.head = builtinEndpoints[.builtin(.head)] + self.patch = builtinEndpoints[.builtin(.patch)] + self.trace = builtinEndpoints[.builtin(.trace)] + self.query = builtinEndpoints[.builtin(.query)] + + self.additionalOperations = OrderedDictionary(otherEndpoints, uniquingKeysWith: { $1 }) } /// An array of all endpoints at this route. public var endpoints: [ResolvedEndpoint] { - [ + let builtins = [ self.get, self.put, self.post, @@ -121,29 +135,27 @@ public struct ResolvedRoute: Equatable { self.trace, self.query ].compactMap { $0 } + + return builtins + additionalOperations.values } /// Retrieve the endpoint for the given method, if one exists for this route. public func `for`(_ verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace - case .query: - return self.query + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + self.additionalOperations[.other(other)] } } diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index 8f7ed5d46..a249fbc95 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -15,6 +15,7 @@ import OpenAPIKitCore public extension OpenAPI { + typealias BuiltinHttpMethod = OpenAPIKitCore.Shared.BuiltinHttpMethod typealias HttpMethod = OpenAPIKitCore.Shared.HttpMethod typealias ContentType = OpenAPIKitCore.Shared.ContentType typealias Error = OpenAPIKitCore.Error diff --git a/Sources/OpenAPIKit30/Document/Document.swift b/Sources/OpenAPIKit30/Document/Document.swift index 0cbab85c5..a47cafaee 100644 --- a/Sources/OpenAPIKit30/Document/Document.swift +++ b/Sources/OpenAPIKit30/Document/Document.swift @@ -685,7 +685,7 @@ internal func validateSecurityRequirements(in paths: OpenAPI.PathItem.Map, again } } -internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at path: OpenAPI.Path, for verb: OpenAPI.HttpMethod, against components: OpenAPI.Components) throws { +internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at path: OpenAPI.Path, for verb: OpenAPI.BuiltinHttpMethod, against components: OpenAPI.Components) throws { let securitySchemes = securityRequirements.flatMap { $0.keys } for securityScheme in securitySchemes { diff --git a/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift b/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift index ed33c127e..9d12735d3 100644 --- a/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift +++ b/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift @@ -52,7 +52,7 @@ public struct ResolvedEndpoint: Equatable { /// The HTTP method of this endpoint. /// /// e.g. GET, POST, PUT, PATCH, etc. - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod /// The path for this endpoint. public let path: OpenAPI.Path /// The parameters this endpoint accepts. diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index a20db00e5..ae9d6e3be 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -77,7 +77,7 @@ public struct DereferencedPathItem: Equatable { extension DereferencedPathItem { /// Retrieve the operation for the given verb, if one is set for this path. - public func `for`(_ verb: OpenAPI.HttpMethod) -> DereferencedOperation? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> DereferencedOperation? { switch verb { case .delete: return self.delete @@ -100,7 +100,7 @@ extension DereferencedPathItem { } } - public subscript(verb: OpenAPI.HttpMethod) -> DereferencedOperation? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> DereferencedOperation? { get { return `for`(verb) } @@ -109,7 +109,7 @@ extension DereferencedPathItem { /// An `Endpoint` is the combination of an /// HTTP method and an operation. public struct Endpoint: Equatable { - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod public let operation: DereferencedOperation } @@ -118,7 +118,7 @@ extension DereferencedPathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in + return OpenAPI.BuiltinHttpMethod.allCases.compactMap { method in self.for(method).map { .init(method: method, operation: $0) } } } diff --git a/Sources/OpenAPIKit30/Path Item/PathItem.swift b/Sources/OpenAPIKit30/Path Item/PathItem.swift index fae4078dd..cceafda35 100644 --- a/Sources/OpenAPIKit30/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/PathItem.swift @@ -19,7 +19,7 @@ extension OpenAPI { /// The `GET` operation, for example, is accessed via the `.get` property. You can /// also use the subscript operator, passing it the `HTTPMethod` you want to access. /// - /// You can access an array of equatable `HttpMethod`/`Operation` paris with the + /// You can access an array of equatable `BuiltinHttpMethod`/`Operation` paris with the /// `endpoints` property. public struct PathItem: Equatable, CodableVendorExtendable, Sendable { public var summary: String? @@ -146,7 +146,7 @@ extension OrderedDictionary where Key == OpenAPI.Path { extension OpenAPI.PathItem { /// Retrieve the operation for the given verb, if one is set for this path. - public func `for`(_ verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> OpenAPI.Operation? { switch verb { case .delete: return self.delete @@ -170,7 +170,7 @@ extension OpenAPI.PathItem { } /// Set the operation for the given verb, overwriting any already set operation for the same verb. - public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.HttpMethod) { + public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.BuiltinHttpMethod) { switch verb { case .delete: self.delete(operation) @@ -194,7 +194,7 @@ extension OpenAPI.PathItem { } } - public subscript(verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> OpenAPI.Operation? { get { return `for`(verb) } @@ -206,7 +206,7 @@ extension OpenAPI.PathItem { /// An `Endpoint` is the combination of an /// HTTP method and an operation. public struct Endpoint: Equatable { - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod public let operation: OpenAPI.Operation } @@ -215,7 +215,7 @@ extension OpenAPI.PathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in + return OpenAPI.BuiltinHttpMethod.allCases.compactMap { method in self.for(method).map { .init(method: method, operation: $0) } } } diff --git a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift index 4f4e27c12..4d678b228 100644 --- a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift @@ -120,7 +120,7 @@ public struct ResolvedRoute: Equatable { } /// Retrieve the endpoint for the given method, if one exists for this route. - public func `for`(_ verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> ResolvedEndpoint? { switch verb { case .delete: return self.delete @@ -143,7 +143,7 @@ public struct ResolvedRoute: Equatable { } } - public subscript(verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> ResolvedEndpoint? { get { return `for`(verb) } diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index 8f7ed5d46..a249fbc95 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -15,6 +15,7 @@ import OpenAPIKitCore public extension OpenAPI { + typealias BuiltinHttpMethod = OpenAPIKitCore.Shared.BuiltinHttpMethod typealias HttpMethod = OpenAPIKitCore.Shared.HttpMethod typealias ContentType = OpenAPIKitCore.Shared.ContentType typealias Error = OpenAPIKitCore.Error diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift index f203b8f3b..7d98b7388 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift @@ -112,3 +112,11 @@ internal struct DecodingErrorWrapper: OpenAPIError { var codingPath: [CodingKey] { decodingError.codingPath } } + +public extension ArraySlice where Element == any CodingKey { + mutating func removeFirstPathComponentString() -> String { + guard !isEmpty else { return "" } + + return removeFirst().stringValue + } +} diff --git a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift index 356dbd354..b6b3579c7 100644 --- a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift +++ b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift @@ -12,7 +12,7 @@ extension Shared { /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.0.4.html#path-item-object) because the supported /// HTTP methods are enumerated as properties on that /// object. - public enum HttpMethod: String, CaseIterable, Sendable { + public enum BuiltinHttpMethod: String, CaseIterable, Sendable { case get = "GET" case post = "POST" case patch = "PATCH" @@ -23,4 +23,89 @@ extension Shared { case trace = "TRACE" case query = "QUERY" } + + public enum HttpMethod: ExpressibleByStringLiteral, RawRepresentable, Equatable, Hashable, Codable, Sendable { + case builtin(BuiltinHttpMethod) + case other(String) + + public static let get = Self.builtin(.get) + public static let post = Self.builtin(.post) + public static let patch = Self.builtin(.patch) + public static let put = Self.builtin(.put) + public static let delete = Self.builtin(.delete) + public static let head = Self.builtin(.head) + public static let options = Self.builtin(.options) + public static let trace = Self.builtin(.trace) + public static let query = Self.builtin(.query) + + public var rawValue: String { + switch self { + case .builtin(let builtin): builtin.rawValue + case .other(let other): other + } + } + + public init?(rawValue: String) { + if let builtin = BuiltinHttpMethod.init(rawValue: rawValue) { + self = .builtin(builtin) + return + } + + let uppercasedValue = rawValue.uppercased() + if Self.additionalKnownUppercaseMethods.contains(uppercasedValue) && rawValue != uppercasedValue { + return nil + } + + // we accept that we do not know the correct capitalization for all + // possible method names and fall back to whatever the user has + // entered. + self = .other(rawValue) + } + + public init(stringLiteral value: String) { + if let valid = Self.init(rawValue: value) { + self = valid + return + } + // we accept that a value may be invalid if it has been hard coded + // as a literal because there is no compile-time evaluation and so + // no way to prevent this without sacrificing code cleanliness. + self = .other(value) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + let attemptedMethod = try container.decode(String.self) + + if let value = Self.init(rawValue: attemptedMethod) { + self = value + return + } + + throw GenericError(subjectName: "HTTP Method", details: "Failed to decode an HTTP method from \(attemptedMethod). This method name must be an uppercased string", codingPath: decoder.codingPath) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.rawValue) + } + + internal static let additionalKnownUppercaseMethods = [ + "LINK", + "CONNECT" + ] + } +} + +extension Shared.HttpMethod: StringConvertibleHintProvider { + public static func problem(with proposedString: String) -> String? { + let uppercasedValue = proposedString.uppercased() + if Self.additionalKnownUppercaseMethods.contains(uppercasedValue) && proposedString != uppercasedValue { + return "'\(proposedString)' must be uppercased" + } + + return nil + } } diff --git a/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift b/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift index d8e3730af..dfd584fd8 100644 --- a/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift @@ -1067,7 +1067,7 @@ final class ValidatorTests: XCTestCase { let validator = Validator.blank .validating( - "All server arrays have not in operations have more than 1 server", + "All server arrays not in operations have more than 1 server", check: \[OpenAPI.Server].count > 1, when: \.codingPath.count == 1 // server array is under root document (coding path count 1) || take(\.codingPath) { codingPath in @@ -1075,7 +1075,7 @@ final class ValidatorTests: XCTestCase { guard codingPath.count > 1 else { return false } let secondToLastPathComponent = codingPath.suffix(2).first!.stringValue - let httpMethods = OpenAPI.HttpMethod.allCases.map { $0.rawValue.lowercased() } + let httpMethods = OpenAPI.BuiltinHttpMethod.allCases.map { $0.rawValue.lowercased() } return !httpMethods.contains(secondToLastPathComponent) } diff --git a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift index 1f097ab3f..5ca4c9448 100644 --- a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift @@ -25,6 +25,7 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertNil(t1[.put]) XCTAssertNil(t1[.trace]) XCTAssertNil(t1[.query]) + XCTAssertEqual(t1.additionalOperations, [:]) // test dynamic member lookup XCTAssertEqual(t1.summary, "test") @@ -43,10 +44,13 @@ final class DereferencedPathItemTests: XCTestCase { head: .init(tags: "head op", responses: [:]), patch: .init(tags: "patch op", responses: [:]), trace: .init(tags: "trace op", responses: [:]), - query: .init(tags: "query op", responses: [:]) + query: .init(tags: "query op", responses: [:]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [:]) + ] ).dereferenced(in: .noComponents) - XCTAssertEqual(t1.endpoints.count, 9) + XCTAssertEqual(t1.endpoints.count, 10) XCTAssertEqual(t1.parameters.map { $0.schemaOrContent.schemaValue?.jsonSchema }, [.string]) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -57,6 +61,7 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.put]?.tags, ["put op"]) XCTAssertEqual(t1[.trace]?.tags, ["trace op"]) XCTAssertEqual(t1[.query]?.tags, ["query op"]) + XCTAssertEqual(t1[.other("LINK")]?.tags, ["link op"]) } func test_referencedParameter() throws { @@ -105,7 +110,8 @@ final class DereferencedPathItemTests: XCTestCase { "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), "trace": .init(description: "trace resp"), - "query": .init(description: "query resp") + "query": .init(description: "query resp"), + "link": .init(description: "link resp") ] ) let t1 = try OpenAPI.PathItem( @@ -117,10 +123,13 @@ final class DereferencedPathItemTests: XCTestCase { head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), - query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [200: .reference(.component(named: "link"))]) + ] ).dereferenced(in: components) - XCTAssertEqual(t1.endpoints.count, 9) + XCTAssertEqual(t1.endpoints.count, 10) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.delete]?.responses[status: 200]?.description, "delete resp") XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -139,6 +148,8 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.trace]?.responses[status: 200]?.description, "trace resp") XCTAssertEqual(t1[.query]?.tags, ["query op"]) XCTAssertEqual(t1[.query]?.responses[status: 200]?.description, "query resp") + XCTAssertEqual(t1[.other("LINK")]?.tags, ["link op"]) + XCTAssertEqual(t1[.other("LINK")]?.responses[status: 200]?.description, "link resp") } func test_missingReferencedGetResp() { @@ -392,4 +403,36 @@ final class DereferencedPathItemTests: XCTestCase { ).dereferenced(in: components) ) } + + func test_missingReferencedAdditionalOperationResp() { + let components = OpenAPI.Components( + responses: [ + "get": .init(description: "get resp"), + "put": .init(description: "put resp"), + "post": .init(description: "post resp"), + "delete": .init(description: "delete resp"), + "options": .init(description: "options resp"), + "head": .init(description: "head resp"), + "patch": .init(description: "patch resp"), + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") + ] + ) + XCTAssertThrowsError( + try OpenAPI.PathItem( + get: .init(tags: "get op", responses: [200: .reference(.component(named: "get"))]), + put: .init(tags: "put op", responses: [200: .reference(.component(named: "put"))]), + post: .init(tags: "post op", responses: [200: .reference(.component(named: "post"))]), + delete: .init(tags: "delete op", responses: [200: .reference(.component(named: "delete"))]), + options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), + head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), + patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [200: .reference(.component(named: "link"))]), + ] + ).dereferenced(in: components) + ) + } } diff --git a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift index 4074021c0..13087fbbd 100644 --- a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift @@ -55,7 +55,10 @@ final class PathItemTests: XCTestCase { head: op, patch: op, trace: op, - query: op + query: op, + additionalOperations: [ + "LINK": op + ] ) } @@ -149,6 +152,57 @@ final class PathItemTests: XCTestCase { "hello/world": .init(), ] } + + func test_endpointsAccessor() { + let op = OpenAPI.Operation(responses: [:]) + let pathItem = OpenAPI.PathItem( + summary: "summary", + description: "description", + servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], + parameters: [.parameter(name: "hello", context: .query, schema: .string)], + get: op, + put: op, + post: op, + delete: op, + options: op, + head: op, + patch: op, + trace: op, + query: op, + additionalOperations: [ + "LINK": op + ] + ) + + let expectedEndpoints : [EquatableEndpoint] = [ + .init(method: .get, operation: op), + .init(method: .put, operation: op), + .init(method: .post, operation: op), + .init(method: .delete, operation: op), + .init(method: .options, operation: op), + .init(method: .head, operation: op), + .init(method: .patch, operation: op), + .init(method: .trace, operation: op), + .init(method: .query, operation: op), + .init(method: "LINK", operation: op) + ] + + let actualEndpoints = pathItem.endpoints.map(equatableEndpoint) + + XCTAssertEqual(actualEndpoints.count, expectedEndpoints.count) + for endpoint in expectedEndpoints { + XCTAssert(actualEndpoints.contains(endpoint)) + } + } +} + +fileprivate struct EquatableEndpoint: Equatable { + let method: OpenAPI.HttpMethod + let operation: OpenAPI.Operation +} + +fileprivate func equatableEndpoint(_ endpoint: OpenAPI.PathItem.Endpoint) -> EquatableEndpoint { + return .init(method: endpoint.method, operation: endpoint.operation) } // MARK: Codable Tests @@ -274,7 +328,10 @@ extension PathItemTests { head: op, patch: op, trace: op, - query: op + query: op, + additionalOperations: [ + "LINK": op + ] ) let encodedPathItem = try orderUnstableTestStringFromEncoding(of: pathItem) @@ -283,6 +340,11 @@ extension PathItemTests { encodedPathItem, """ { + "additionalOperations" : { + "LINK" : { + + } + }, "delete" : { }, @@ -336,11 +398,19 @@ extension PathItemTests { "trace" : { }, "query" : { + }, + "additionalOperations": { + "LINK": { + }, + "CONNECT": { + }, + "unknown_method": { + }, } } """.data(using: .utf8)! - let pathItem = try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData) + let pathItem = try orderStableDecode(OpenAPI.PathItem.self, from: pathItemData) let op = OpenAPI.Operation(responses: [:]) @@ -355,11 +425,84 @@ extension PathItemTests { head: op, patch: op, trace: op, - query: op + query: op, + additionalOperations: [ + "LINK": op, + "CONNECT": op, + "unknown_method": op + ] ) ) } + func test_disallowedAdditionalOperations_decode() throws { + // NOTE the one allowed method in the following is LINK which is there + // to ensure allowed methods do not show up in the error output. + let pathItemData = + """ + { + "additionalOperations": { + "LINK": { + }, + "DELETE" : { + }, + "GET" : { + }, + "HEAD" : { + }, + "OPTIONS" : { + }, + "PATCH" : { + }, + "POST" : { + }, + "PUT" : { + }, + "TRACE" : { + }, + "QUERY" : { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderStableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `additionalOperations` under the `/` path: Additional Operations cannot contain operations that can be set directly on the Path Item. Found the following disallowed additional operations: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE, QUERY.") + } + } + + func test_invalidAdditionalOperation1_decode() throws { + let pathItemData = + """ + { + "additionalOperations": { + "connect": { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `connect` under the `/` path: 'connect' must be uppercased.") + } + } + + func test_invalidAdditionalOperation2_decode() throws { + let pathItemData = + """ + { + "additionalOperations": { + "link": { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `link` under the `/` path: 'link' must be uppercased.") + } + } + func test_pathComponents_encode() throws { let test: [OpenAPI.Path] = ["/hello/world", "hi/there"] diff --git a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift index 96d4b8214..246fe1cf2 100644 --- a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift @@ -55,6 +55,12 @@ final class ResolvedRouteTests: XCTestCase { summary: "query", responses: [200: .response(description: "hello world")] ), + additionalOperations: [ + "LINK": .init( + summary: "link", + responses: [200: .response(description: "hello world")] + ) + ], vendorExtensions: [ "test": "route" ] @@ -81,8 +87,9 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.patch?.endpointSummary, "patch") XCTAssertEqual(routes.first?.trace?.endpointSummary, "trace") XCTAssertEqual(routes.first?.query?.endpointSummary, "query") + XCTAssertEqual(routes.first?.additionalOperations["LINK"]?.endpointSummary, "link") - XCTAssertEqual(routes.first?.endpoints.count, 9) + XCTAssertEqual(routes.first?.endpoints.count, 10) XCTAssertEqual(routes.first?.get, routes.first?[.get]) XCTAssertEqual(routes.first?.put, routes.first?[.put]) @@ -93,6 +100,7 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.patch, routes.first?[.patch]) XCTAssertEqual(routes.first?.trace, routes.first?[.trace]) XCTAssertEqual(routes.first?.query, routes.first?[.query]) + XCTAssertEqual(routes.first?.additionalOperations["LINK"], routes.first?[.other("LINK")]) } func test_pathServersTakePrecedence() throws { diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index 2278b8974..6c17f8b29 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1067,7 +1067,7 @@ final class ValidatorTests: XCTestCase { let validator = Validator.blank .validating( - "All server arrays have not in operations have more than 1 server", + "All server arrays not in operations have more than 1 server", check: \[OpenAPI.Server].count > 1, when: \.codingPath.count == 1 // server array is under root document (coding path count 1) || take(\.codingPath) { codingPath in @@ -1075,7 +1075,7 @@ final class ValidatorTests: XCTestCase { guard codingPath.count > 1 else { return false } let secondToLastPathComponent = codingPath.suffix(2).first!.stringValue - let httpMethods = OpenAPI.HttpMethod.allCases.map { $0.rawValue.lowercased() } + let httpMethods = OpenAPI.BuiltinHttpMethod.allCases.map { $0.rawValue.lowercased() } return !httpMethods.contains(secondToLastPathComponent) } From 832263a03a932705b0a27b95a13bb95e6c8a1f7f Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 24 Oct 2025 09:25:16 -0500 Subject: [PATCH 14/73] catch the spec coverage list up --- documentation/specification_coverage.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/documentation/specification_coverage.md b/documentation/specification_coverage.md index 761130a35..92af640d8 100644 --- a/documentation/specification_coverage.md +++ b/documentation/specification_coverage.md @@ -113,6 +113,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] patch - [x] trace - [x] query +- [x] additionalOperations - [x] servers - [x] parameters - [x] specification extensions (`vendorExtensions`) @@ -221,8 +222,11 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Tag Object (`OpenAPI.Tag`) - [x] name +- [x] summary - [x] description - [x] externalDocs +- [x] parent +- [x] kind - [x] specification extensions (`vendorExtensions`) ### Reference Object (`OpenAPI.Reference`) From 0550e8a7c1ffffd0fbc3caba9fcda35b623aaf20 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 24 Oct 2025 10:29:10 -0500 Subject: [PATCH 15/73] update README and begin writing migration guide --- README.md | 81 ++++++++----------- .../v2_migration_guide.md | 0 .../v3_migration_guide.md | 0 .../v4_migration_guide.md | 0 .../migration_guides/v5_migration_guide.md | 72 +++++++++++++++++ 5 files changed, 105 insertions(+), 48 deletions(-) rename documentation/{ => migration_guides}/v2_migration_guide.md (100%) rename documentation/{ => migration_guides}/v3_migration_guide.md (100%) rename documentation/{ => migration_guides}/v4_migration_guide.md (100%) create mode 100644 documentation/migration_guides/v5_migration_guide.md diff --git a/README.md b/README.md index 5c9591a79..b1d83ca0a 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@ # OpenAPIKit -A library containing Swift types that encode to- and decode from [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4.html) and [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.1.html) Documents and their components. +A library containing Swift types that encode to- and decode from [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4.html), [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.2.html), and [OpenAPI 3.2.x](https://spec.openapis.org/oas/v3.2.0.html) Documents and their components. OpenAPIKit follows semantic versioning despite the fact that the OpenAPI specificaiton does not. The following chart shows which OpenAPI specification versions and key features are supported by which OpenAPIKit versions. -| OpenAPIKit | Swift | OpenAPI v3.0 | OpenAPI v3.1 | External Dereferencing & Sendable | -|------------|-------|--------------|--------------|-----------------------------------| -| v2.x | 5.1+ | ✅ | | | -| v3.x | 5.1+ | ✅ | ✅ | | -| v4.x | 5.8+ | ✅ | ✅ | ✅ | +| OpenAPIKit | Swift | OpenAPI v3.0, v3.1 | External Dereferencing & Sendable | OpenAPI v3.2 | +|------------|-------|--------------------|-----------------------------------|--------------| +| v3.x | 5.1+ | ✅ | | | +| v4.x | 5.8+ | ✅ | ✅ | | +| v4.x | 5.8+ | ✅ | ✅ | ✅ | - [Usage](#usage) - [Migration](#migration) - - [1.x to 2.x](#1.x-to-2.x) - - [2.x to 3.x](#2.x-to-3.x) - - [3.x to 4.x](#3.x-to-4.x) + - [Older Versions](#older-versions) + - [3.x to 4.x](#3x-to-4x) + - [4.x to 5.x](#4x-to-5x) - [Decoding OpenAPI Documents](#decoding-openapi-documents) - [Decoding Errors](#decoding-errors) - [Encoding OpenAPI Documents](#encoding-openapi-documents) @@ -47,40 +47,25 @@ OpenAPIKit follows semantic versioning despite the fact that the OpenAPI specifi ## Usage ### Migration -#### 1.x to 2.x -If you are migrating from OpenAPIKit 1.x to OpenAPIKit 2.x, check out the [v2 migration guide](./documentation/v2_migration_guide.md). +#### 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) -#### 2.x to 3.x -If you are migrating from OpenAPIKit 2.x to OpenAPIKit 3.x, check out the [v3 migration guide](./documentation/v3_migration_guide.md). - -You will need to start being explicit about which of the two new modules you want to use in your project: `OpenAPIKit` (now supports OpenAPI spec v3.1) and/or `OpenAPIKit30` (continues to support OpenAPI spec v3.0 like the previous versions of OpenAPIKit did). - -In package manifests, dependencies will be one of: -``` -// v3.0 of spec: -dependencies: [.product(name: "OpenAPIKit30", package: "OpenAPIKit")] - -// v3.1 of spec: -dependencies: [.product(name: "OpenAPIKit", package: "OpenAPIKit")] -``` - -Your imports need to be specific as well: -```swift -// v3.0 of spec: -import OpenAPIKit30 +#### 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). -// v3.1 of spec: -import OpenAPIKit -``` +Be aware of the changes to minimum Swift version and minimum Yams version (although Yams is only a test dependency of OpenAPIKit). -It is recommended that you build your project against the `OpenAPIKit` module and only use `OpenAPIKit30` to support reading OpenAPI 3.0.x documents in and then [converting them](#supporting-openapi-30x-documents) to OpenAPI 3.1.x documents. The situation not supported yet by this strategy is where you need to write out an OpenAPI 3.0.x document (as opposed to 3.1.x). That is a planned feature but it has not yet been implemented. If your use-case benefits from reading in an OpenAPI 3.0.x document and also writing out an OpenAPI 3.0.x document then you can operate entirely against the `OpenAPIKit30` module. +#### 4.x to 5.x +If you are migrating from OpenAPIKit 4.x to OpenAPIKit 5.x, check out the [v5 migration guide](./documentation/migration_guides/v5_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/v4_migration_guide.md). +Be aware of the change to minimum Swift version. ### Decoding OpenAPI Documents -Most documentation will focus on what it looks like to work with the `OpenAPIKit` module and OpenAPI 3.1.x documents. If you need to support OpenAPI 3.0.x documents, take a look at the section on [supporting OpenAPI 3.0.x documents](#supporting-openapi-30x-documents) before you get too deep into this library's docs. +Most documentation will focus on what it looks like to work with the `OpenAPIKit` module and OpenAPI 3.2.x documents. If you need to support OpenAPI 3.0.x documents, take a look at the section on [supporting OpenAPI 3.0.x documents](#supporting-openapi-30x-documents) before you get too deep into this library's docs. + +Version 3.2.x of the OpenAPI Specification is backwards compatible with version 3.1.x of the specification but it adds some new features. The OpenAPIKit types support these new features regardless of what the stated Document version is, but if a Document states that it is version 3.1.x and it uses OAS 3.2.x features then OpenAPIKit will produce a warning. If you run strict validations on the document, those warnings will be errors. If you choose not to run strict validations on the document, you can handle such a document leniently. You can decode a JSON OpenAPI document (i.e. using the `JSONDecoder` from **Foundation** library) or a YAML OpenAPI document (i.e. using the `YAMLDecoder` from the [**Yams**](https://github.com/jpsim/Yams) library) with the following code: ```swift @@ -148,21 +133,21 @@ You can use this same validation system to dig arbitrarily deep into an OpenAPI ### Supporting OpenAPI 3.0.x Documents If you need to operate on OpenAPI 3.0.x documents and only 3.0.x documents, you can use the `OpenAPIKit30` module throughout your code. -However, if you need to operate on both OpenAPI 3.0.x and 3.1.x documents, the recommendation is to use the OpenAPIKit compatibility layer to read in a 3.0.x document and convert it to a 3.1.x document so that you can use just the one set of Swift types throughout most of your program. An example of that follows. +However, if you need to operate on both OpenAPI 3.0.x and 3.1.x/3.2.x documents, the recommendation is to use the OpenAPIKit compatibility layer to read in a 3.0.x document and convert it to a 3.1.x or 3.2.x document so that you can use just the one set of Swift types throughout most of your program. An example of that follows. -In this example, only one file in the whole project needs to import `OpenAPIKit30` or `OpenAPIKitCompat`. Every other file would just import `OpenAPIKit` and work with the document in the 3.1.x format. +In this example, only one file in the whole project needs to import `OpenAPIKit30` or `OpenAPIKitCompat`. Every other file would just import `OpenAPIKit` and work with the document in the 3.2.x format. -#### Converting from 3.0.x to 3.1.x +#### Converting from 3.0.x to 3.2.x ```swift // import OpenAPIKit30 for OpenAPI 3.0 document support import OpenAPIKit30 -// import OpenAPIKit for OpenAPI 3.1 document support +// import OpenAPIKit for OpenAPI 3.2 document support import OpenAPIKit // import OpenAPIKitCompat to convert between the versions import OpenAPIKitCompat -// if most of your project just works with OpenAPI v3.1, most files only need to import OpenAPIKit. -// Only in the file where you are supporting converting from OpenAPI v3.0 to v3.1 do you need the +// if most of your project just works with OpenAPI v3.2, most files only need to import OpenAPIKit. +// Only in the file where you are supporting converting from OpenAPI v3.0 to v3.2 do you need the // other two imports. // we can support either version by attempting to parse an old version and then a new version if the old version fails @@ -171,12 +156,12 @@ let newDoc: OpenAPIKit.OpenAPI.Document oldDoc = try? JSONDecoder().decode(OpenAPI.Document.self, from: someFileData) -newDoc = oldDoc?.convert(to: .v3_1_1) ?? +newDoc = oldDoc?.convert(to: .v3_2_0) ?? (try! JSONDecoder().decode(OpenAPI.Document.self, from: someFileData)) -// ^ Here we simply fall-back to 3.1.x if loading as 3.0.x failed. You could do a more +// ^ Here we simply fall-back to 3.2.x if loading as 3.0.x failed. You could do a more // graceful job of this by determining up front which version to attempt to load or by // holding onto errors for each decode attempt so you can tell the user why the document -// failed to decode as neither 3.0.x nor 3.1.x if it fails in both cases. +// failed to decode as neither 3.0.x nor 3.2.x if it fails in both cases. ``` ### A note on dictionary ordering @@ -187,7 +172,7 @@ If retaining order is important for your use-case, I recommend the [**Yams**](ht The Foundation JSON encoding and decoding will be the most stable and battle-tested option with Yams as a pretty well established and stable option as well. FineJSON is lesser used (to my knowledge) but I have had success with it in the past. ### OpenAPI Document structure -The types used by this library largely mirror the object definitions found in the OpenAPI specification [version 3.1.1](https://spec.openapis.org/oas/v3.1.1.html) (`OpenAPIKit` module) and [version 3.0.4](https://spec.openapis.org/oas/v3.0.4.html) (`OpenAPIKit30` module). The [Project Status](#project-status) lists each object defined by the spec and the name of the respective type in this library. The project status page currently focuses on OpenAPI 3.1.x but for the purposes of determining what things are named and what is supported you can mostly infer the status of the OpenAPI 3.0.x support as well. +The types used by this library largely mirror the object definitions found in the OpenAPI specification [version 3.2.0](https://spec.openapis.org/oas/v3.2.0.html) (`OpenAPIKit` module) and [version 3.0.4](https://spec.openapis.org/oas/v3.0.4.html) (`OpenAPIKit30` module). The [Project Status](#project-status) lists each object defined by the spec and the name of the respective type in this library. The project status page currently focuses on OpenAPI 3.2.x but for the purposes of determining what things are named and what is supported you can mostly infer the status of the OpenAPI 3.0.x support as well. #### Document Root At the root there is an `OpenAPI.Document`. In addition to some information that applies to the entire API, the document contains `OpenAPI.Components` (essentially a dictionary of reusable components that can be referenced with `JSONReferences` and `OpenAPI.References`) and an `OpenAPI.PathItem.Map` (a dictionary of routes your API defines). @@ -210,7 +195,7 @@ A schema can be made **optional** (i.e. it can be omitted) with `JSONSchema.inte A schema can be made **nullable** with `JSONSchema.number(nullable: true)` or an existing schema can be asked for a `nullableSchemaObject()`. -Nullability highlights an important decision OpenAPIKit makes. The JSON Schema specification that dictates how OpenAPI v3.1 documents _encode_ nullability states that a nullable property is encoded as having the `null` type in addition to whatever other type(s) it has. So in OpenAPIKit you set `nullability` as a property of a schema, but when encoded/decoded it will represent the inclusion of absence of `null` in the list of `type`s of the schema. If you are using the `OpenAPIKit30` module then nullability is encoded as a `nullable` property per the OpenAPI 3.0.x specification. +Nullability highlights an important decision OpenAPIKit makes. The JSON Schema specification that dictates how OpenAPI v3.2 documents _encode_ nullability states that a nullable property is encoded as having the `null` type in addition to whatever other type(s) it has. So in OpenAPIKit you set `nullability` as a property of a schema, but when encoded/decoded it will represent the inclusion of absence of `null` in the list of `type`s of the schema. If you are using the `OpenAPIKit30` module then nullability is encoded as a `nullable` property per the OpenAPI 3.0.x specification. Some types of schemas can be further specialized with a **format**. For example, `JSONSchema.number(format: .double)` or `JSONSchema.string(format: .dateTime)`. @@ -311,7 +296,7 @@ let document = OpenAPI.Document( ``` #### Specification Extensions -Many OpenAPIKit types support [Specification Extensions](https://spec.openapis.org/oas/v3.1.1.html#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. +Many OpenAPIKit types support [Specification Extensions](https://spec.openapis.org/oas/v3.2.0.html#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. You can get or set specification extensions via the [`vendorExtensions`](https://mattpolzin.github.io/OpenAPIKit/documentation/openapikit/vendorextendable/vendorextensions-swift.property) property on any object that supports this feature. The keys are `Strings` beginning with the aforementioned "x-" prefix and the values are `AnyCodable`. If you set an extension without using the "x-" prefix, the prefix will be added upon encoding. diff --git a/documentation/v2_migration_guide.md b/documentation/migration_guides/v2_migration_guide.md similarity index 100% rename from documentation/v2_migration_guide.md rename to documentation/migration_guides/v2_migration_guide.md diff --git a/documentation/v3_migration_guide.md b/documentation/migration_guides/v3_migration_guide.md similarity index 100% rename from documentation/v3_migration_guide.md rename to documentation/migration_guides/v3_migration_guide.md diff --git a/documentation/v4_migration_guide.md b/documentation/migration_guides/v4_migration_guide.md similarity index 100% rename from documentation/v4_migration_guide.md rename to documentation/migration_guides/v4_migration_guide.md diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md new file mode 100644 index 000000000..452cebf4e --- /dev/null +++ b/documentation/migration_guides/v5_migration_guide.md @@ -0,0 +1,72 @@ +## OpenAPIKit v5 Migration Guide +For general information on the v5 release, see the release notes on GitHub. The +rest of this guide will be formatted as a series of changes and what options you +have to migrate code from v4 to v5. You can also refer back to the release notes +for each of the v4 pre-releases for the most thorough look at what changed. + +This guide will not spend time on strictly additive features of version 5. See +the release notes, README, and documentation for information on new features. + +### Swift version support +OpenAPIKit v5.0 drops support for Swift versions prior to 5.10 (i.e. it supports +v5.10 and greater). + +### MacOS version support +Only relevant when compiling OpenAPIKit on iOS: Now v12+ is required. + +### OpenAPI Specification Versions +The OpenAPIKit module's `OpenAPI.Document.Version` enum gained `v3_1_2`, +`v3_2_0` and `v3_2_x(x: Int)`. + +If you have exhaustive switches over values of those types then your switch +statements will need to be updated. + +If you use `v3_1_x(x: 2)` you should replace it with `v3_1_2`. + +### Content Types +The `application/x-yaml` media type is officially superseded by +`application/yaml`. OpenAPIKit will continue to support reading the +`application/x-yaml` media type, but it will always choose to encode the YAML +media type as `application/yaml`. + +### Http Methods +The `OpenAPIKit30` module's `OpenAPI.HttpMethod` type has been renamed to +`OpenAPI.BuiltinHttpMethod` and gained the `.query` method (though this method +cannot be represented on the OAS 3.0.x Path Item Object). + +The `OpenAPI` module's `OpenAPI.HttpMethod` type has been updated to support +non-builtin HTTP methods with the pre-existing HTTP methods moving to the +`OpenAPI.BuiltinHttpMethod` type and `HttpMethod` having just two cases: +`.builtin(BuiltinHttpMethod)` and `.other(String)`. + +Switch statements over `OpenAPI.HttpMethod` should be updated to first check if +the method is builtin or not: +```swift +switch httpMethod { +case .builtin(let builtin): + switch builtin { + case .delete: // ... + case .get: // ... + case .head: // ... + case .options: // ... + case .patch: // ... + case .post: // ... + case .put: // ... + case .trace: // ... + case .query: // ... + } +case .other(let other): + // new stuff to handle here +} +``` + +You can continue to use static constructors on `OpenAPI.HttpMethod` to construct +builtin methods so the following code _does not need to change_: +```swift +let httpMethod : OpenAPI.HttpMethod = .post +``` + +### 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 +expected values. From 495a63d12c7fedb7efbd01da4248a5e440895d94 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 24 Oct 2025 10:35:18 -0500 Subject: [PATCH 16/73] add a few more tidbits to the docs for the HttpMethod type --- Sources/OpenAPIKitCore/Shared/HttpMethod.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift index b6b3579c7..f6f0f0d7a 100644 --- a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift +++ b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift @@ -9,8 +9,8 @@ extension Shared { /// Represents the HTTP methods supported by the /// OpenAPI Specification. /// - /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.0.4.html#path-item-object) because the supported - /// HTTP methods are enumerated as properties on that + /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.2.0.html#path-item-object) + /// because the supported HTTP methods are enumerated as properties on that /// object. public enum BuiltinHttpMethod: String, CaseIterable, Sendable { case get = "GET" @@ -24,6 +24,17 @@ extension Shared { case query = "QUERY" } + /// Represents an HTTP method. + /// + /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.2.0.html#path-item-object). + /// + /// Methods are split into builtin methods (those representable as + /// properties on a Path Item Object) and other methods (those that can be + /// added to the `additionalOperations` of a Path Item Object). + /// + /// `HttpMethod` is `ExpressibleByStringLiteral` so you can write a + /// non-builtin method like "LINK" as: + /// `let linkMethod : OpenAPI.HttpMethod = "LINK"` public enum HttpMethod: ExpressibleByStringLiteral, RawRepresentable, Equatable, Hashable, Codable, Sendable { case builtin(BuiltinHttpMethod) case other(String) From 38b3deb1270902c9568ae7abf64810dafedcac25 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 26 Oct 2025 19:57:45 -0500 Subject: [PATCH 17/73] support oas 3.2.0 $self property of the Document object --- Sources/OpenAPIKit/Document/Document.swift | 67 ++++++++++++++- .../Document/DocumentTests.swift | 85 +++++++++++++++++-- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index 93119bf8a..d2a591c85 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -6,6 +6,7 @@ // import OpenAPIKitCore +import Foundation extension OpenAPI { /// The root of an OpenAPI 3.1 document. @@ -45,7 +46,7 @@ extension OpenAPI { /// /// See the documentation on `DereferencedDocument.resolved()` for more. /// - public struct Document: HasWarnings, CodableVendorExtendable, Sendable { + public struct Document: HasConditionalWarnings, HasWarnings, CodableVendorExtendable, Sendable { /// OpenAPI Spec "openapi" field. /// /// OpenAPIKit only explicitly supports versions that can be found in @@ -53,6 +54,9 @@ extension OpenAPI { /// by OpenAPIKit to a certain extent. public var openAPIVersion: Version + /// OpenAPI Spec "$self" field. + public var selfURI: URL? + /// Information about the API described by this OpenAPI Document. /// /// Licensing, Terms of Service, contact information, API version (the @@ -142,9 +146,11 @@ extension OpenAPI { public var vendorExtensions: [String: AnyCodable] public let warnings: [Warning] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] public init( openAPIVersion: Version = .v3_1_1, + selfURI: URL? = nil, info: Info, servers: [Server], paths: PathItem.Map, @@ -156,6 +162,7 @@ extension OpenAPI { vendorExtensions: [String: AnyCodable] = [:] ) { self.openAPIVersion = openAPIVersion + self.selfURI = selfURI self.info = info self.servers = servers self.paths = paths @@ -167,13 +174,28 @@ extension OpenAPI { self.vendorExtensions = vendorExtensions self.warnings = [] + + self.conditionalWarnings = [ + // If $self is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0), + ].compactMap { $0 } } } } +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Document \(fieldName) field" + ) + } +} + extension OpenAPI.Document: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.openAPIVersion == rhs.openAPIVersion + && lhs.selfURI == rhs.selfURI && lhs.info == rhs.info && lhs.servers == rhs.servers && lhs.paths == rhs.paths @@ -602,6 +624,9 @@ extension OpenAPI.Document: Encodable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(openAPIVersion, forKey: .openAPIVersion) + + try container.encodeIfPresent(selfURI?.absoluteString, forKey: .selfURI) + try container.encode(info, forKey: .info) try container.encodeIfPresent(externalDocs, forKey: .externalDocs) @@ -661,6 +686,11 @@ extension OpenAPI.Document: Decodable { ) } + let selfURIString: String? = try container.decodeIfPresent(String.self, forKey: .selfURI) + selfURI = try selfURIString.map { + try decodeURIString($0, forKey: CodingKeys.selfURI, atPath: decoder.codingPath) + } + info = try container.decode(OpenAPI.Document.Info.self, forKey: .info) servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? [] @@ -681,6 +711,11 @@ extension OpenAPI.Document: Decodable { self.warnings = warnings + self.conditionalWarnings = [ + // If $self is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0), + ].compactMap { $0 } + } catch let error as OpenAPI.Error.Decoding.Path { throw OpenAPI.Error.Decoding.Document(error) @@ -697,9 +732,34 @@ extension OpenAPI.Document: Decodable { } } +fileprivate func decodeURIString(_ str: String, forKey key: CodingKey, atPath path: [CodingKey]) throws -> URL { + let uri: URL? + #if canImport(FoundationEssentials) + uri = URL(string: str, 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, *) { + uri = URL(string: str, encodingInvalidCharacters: false) + } else { + uri = URL(string: str) + } + #else + uri = URL(string: str) + #endif + guard let uri else { + throw GenericError( + subjectName: key.stringValue, + details: "Failed to parse a valid URI from '\(str)'", + codingPath: path + ) + } + + return uri +} + extension OpenAPI.Document { internal enum CodingKeys: ExtendableCodingKey { case openAPIVersion + case selfURI case info case jsonSchemaDialect // TODO: implement parsing (https://github.com/mattpolzin/OpenAPIKit/issues/202) case servers @@ -714,6 +774,7 @@ extension OpenAPI.Document { static var allBuiltinKeys: [CodingKeys] { return [ .openAPIVersion, + .selfURI, .info, .jsonSchemaDialect, .servers, @@ -734,6 +795,8 @@ extension OpenAPI.Document { switch stringValue { case "openapi": self = .openAPIVersion + case "$self": + self = .selfURI case "info": self = .info case "jsonSchemaDialect": @@ -761,6 +824,8 @@ extension OpenAPI.Document { switch self { case .openAPIVersion: return "openapi" + case .selfURI: + return "$self" case .info: return "info" case .jsonSchemaDialect: diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 166e12879..ce7d142e1 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -20,6 +20,7 @@ final class DocumentTests: XCTestCase { let _ = OpenAPI.Document( openAPIVersion: .v3_1_0, + selfURI: .init(string: "https://example.com/openapi")!, info: .init(title: "hi", version: "1.0"), servers: [ .init(url: URL(string: "https://google.com")!) @@ -135,7 +136,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: nil, responses: [:])), "/hello/world": .init( - put: .init(operationId: nil, responses: [:])) + put: .init(operationId: nil, responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -150,7 +156,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: "test", responses: [:])), "/hello/world": .init( - put: .init(operationId: nil, responses: [:])) + put: .init(operationId: nil, responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -165,12 +176,17 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: "test", responses: [:])), "/hello/world": .init( - put: .init(operationId: "two", responses: [:])) + put: .init(operationId: "two", responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: "three", responses: [:]) + ] + ) ], components: .noComponents ) - XCTAssertEqual(t3.allOperationIds, ["test", "two"]) + XCTAssertEqual(t3.allOperationIds, ["test", "two", "three"]) // paths, one operation id (first one nil), no components, no webhooks let t4 = OpenAPI.Document( @@ -180,7 +196,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: nil, responses: [:])), "/hello/world": .init( - put: .init(operationId: "two", responses: [:])) + put: .init(operationId: "two", responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -690,6 +711,60 @@ extension DocumentTests { ) } + func test_specifySelfURI_encode() throws { + let document = OpenAPI.Document( + selfURI: .init(string: "https://example.com/openapi")!, + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents + ) + let encodedDocument = try orderUnstableTestStringFromEncoding(of: document) + + assertJSONEquivalent( + encodedDocument, + """ + { + "$self" : "https:\\/\\/example.com\\/openapi", + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1" + } + """ + ) + } + + func test_specifySelfURI_decode() throws { + let documentData = + """ + { + "$self": "https://example.com/openapi", + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1", + "paths" : { + + } + } + """.data(using: .utf8)! + let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData) + + XCTAssertEqual( + document, + OpenAPI.Document( + selfURI: .init(string: "https://example.com/openapi")!, + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents + ) + ) + } + func test_specifyPaths_encode() throws { let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), From 71912da60436567a034165fbdbdd6e48ad72eceb Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 27 Oct 2025 08:45:43 -0500 Subject: [PATCH 18/73] Add new querystring parameter location --- Sources/OpenAPIKit/Parameter/Parameter.swift | 13 ++++++++++++ .../Parameter/ParameterContext.swift | 16 ++++++++++++++- .../Parameter/ParameterContextLocation.swift | 20 +++++++++++++++++++ .../Parameter/ParameterSchemaContext.swift | 2 ++ Sources/OpenAPIKit/_CoreReExport.swift | 4 ---- .../Parameter/ParameterContextLocation.swift | 19 ++++++++++++++++++ Sources/OpenAPIKit30/_CoreReExport.swift | 4 ---- .../Shared/ParameterContextLocation.swift | 17 ---------------- 8 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift create mode 100644 Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift delete mode 100644 Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index 20b3843ad..754a32ad3 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -254,6 +254,9 @@ extension OpenAPI.Parameter: Encodable { case .cookie(required: let req): required = req location = .cookie + case .querystring(required: let req): + required = req + location = .querystring } try container.encode(location, forKey: .parameterLocation) @@ -307,6 +310,8 @@ extension OpenAPI.Parameter: Decodable { context = .path case .cookie: context = .cookie(required: required) + case .querystring: + context = .querystring(required: required) } let maybeContent = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) @@ -318,6 +323,14 @@ extension OpenAPI.Parameter: Decodable { maybeSchema = nil } + if location == .querystring && maybeSchema != nil { + throw GenericError( + subjectName: name, + details: "`schema` and `style` are disallowed for `querystring` parameters", + codingPath: decoder.codingPath + ) + } + switch (maybeContent, maybeSchema) { case (let content?, nil): schemaOrContent = .init(content) diff --git a/Sources/OpenAPIKit/Parameter/ParameterContext.swift b/Sources/OpenAPIKit/Parameter/ParameterContext.swift index d017b7c67..e4530d238 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterContext.swift @@ -21,6 +21,7 @@ extension OpenAPI.Parameter { case header(required: Bool) case path case cookie(required: Bool) + case querystring(required: Bool) public static func query(required: Bool) -> Context { return .query(required: required, allowEmptyValue: false) } @@ -36,6 +37,9 @@ extension OpenAPI.Parameter { /// An optional cookie parameter. public static var cookie: Context { return .cookie(required: false) } + /// An optional querystring parameter. + public static var querystring: Context { return .querystring(required: false) } + public var inQuery: Bool { guard case .query = self else { return false @@ -59,11 +63,19 @@ extension OpenAPI.Parameter { return true } + public var inQuerystring: Bool { + guard case .querystring = self else { + return false + } + return true + } + public var required: Bool { switch self { case .query(required: let required, allowEmptyValue: _), .header(required: let required), - .cookie(required: let required): + .cookie(required: let required), + .querystring(required: let required): return required case .path: return true @@ -83,6 +95,8 @@ extension OpenAPI.Parameter.Context { return .path case .cookie: return .cookie + case .querystring: + return .querystring } } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift b/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift new file mode 100644 index 000000000..7c5099b4d --- /dev/null +++ b/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift @@ -0,0 +1,20 @@ +// +// ParameterContextLocation.swift +// +// +// Created by Mathew Polzin on 12/24/22. +// + +import OpenAPIKitCore + +extension OpenAPI.Parameter.Context { + public enum Location: String, CaseIterable, Codable { + case query + case header + case path + case cookie + case querystring + } +} + +extension OpenAPI.Parameter.Context.Location: Validatable {} diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index f8828da2e..2928db7d2 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -143,6 +143,8 @@ extension OpenAPI.Parameter.SchemaContext.Style { return .simple case .header: return .simple + case .querystring: + return .simple } } diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index a249fbc95..997063193 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -31,10 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.Context { - typealias Location = OpenAPIKitCore.Shared.ParameterContextLocation -} - public extension OpenAPI.Parameter.SchemaContext { typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle } diff --git a/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift b/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift new file mode 100644 index 000000000..89d323021 --- /dev/null +++ b/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift @@ -0,0 +1,19 @@ +// +// ParameterContextLocation.swift +// +// +// Created by Mathew Polzin on 12/24/22. +// + +import OpenAPIKitCore + +extension OpenAPI.Parameter.Context { + public enum Location: String, CaseIterable, Codable { + case query + case header + case path + case cookie + } +} + +extension OpenAPI.Parameter.Context.Location: Validatable {} diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index a249fbc95..997063193 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -31,10 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.Context { - typealias Location = OpenAPIKitCore.Shared.ParameterContextLocation -} - public extension OpenAPI.Parameter.SchemaContext { typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle } diff --git a/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift b/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift deleted file mode 100644 index 1a36cc8e0..000000000 --- a/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ParameterContextLocation.swift -// -// -// Created by Mathew Polzin on 12/24/22. -// - -extension Shared { - public enum ParameterContextLocation: String, CaseIterable, Codable { - case query - case header - case path - case cookie - } -} - -extension Shared.ParameterContextLocation: Validatable {} From 79ab6d845e0c60e84034328abb5f903f58e3ddab Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 27 Oct 2025 09:41:51 -0500 Subject: [PATCH 19/73] move parameter schema or content into parameter context --- .../Parameter/DereferencedParameter.swift | 59 +- Sources/OpenAPIKit/Parameter/Parameter.swift | 534 +++++++++++++----- .../Parameter/ParameterContext.swift | 93 ++- .../Parameter/ParameterSchemaContext.swift | 65 ++- Sources/OpenAPIKitCompat/Compat30To31.swift | 46 +- .../DocumentConversionTests.swift | 7 +- Tests/OpenAPIKitTests/ComponentsTests.swift | 8 +- Tests/OpenAPIKitTests/EaseOfUseTests.swift | 66 +-- .../OpenAPIReferenceTests.swift | 2 +- .../DereferencedOperationTests.swift | 9 +- .../Operation/OperationTests.swift | 2 +- .../Operation/ResolvedEndpointTests.swift | 12 +- .../DereferencedParameterTests.swift | 21 +- .../Parameter/ParameterContextTests.swift | 34 +- .../Parameter/ParameterSchemaTests.swift | 17 +- .../Parameter/ParameterTests.swift | 205 +++---- .../Path Item/DereferencedPathItemTests.swift | 4 +- .../Path Item/PathItemTests.swift | 8 +- .../Path Item/ResolvedRouteTests.swift | 2 +- .../Validator/BuiltinValidationTests.swift | 26 +- .../Validation+ConvenienceTests.swift | 8 +- 21 files changed, 787 insertions(+), 441 deletions(-) diff --git a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift index 4b5002ca3..23c079e82 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift @@ -90,26 +90,59 @@ extension OpenAPI.Parameter: ExternallyDereferenceable { // next line: // let (newSchemaOrContent, components) = try await schemaOrContent.externallyDereferenced(with: loader) - let newSchemaOrContent: Either + let newContext: OpenAPI.Parameter.Context let newComponents: OpenAPI.Components let newMessages: [Loader.Message] - switch schemaOrContent { - case .a(let schemaContext): - let (context, components, messages) = try await schemaContext.externallyDereferenced(with: loader) - newSchemaOrContent = .a(context) - newComponents = components - newMessages = messages - case .b(let contentMap): - let (map, components, messages) = try await contentMap.externallyDereferenced(with: loader) - newSchemaOrContent = .b(map) - newComponents = components - newMessages = messages + switch context { + case .query(required: let required, allowEmptyValue: let allowEmptyValue, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: newSchemaOrContent) + + case .header(required: let required, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .header(required: required, schemaOrContent: newSchemaOrContent) + + case .path(schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .path(schemaOrContent: newSchemaOrContent) + + case .cookie(required: let required, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .cookie(required: required, schemaOrContent: newSchemaOrContent) + + case .querystring(required: let required, content: let content): + let newContent: OpenAPI.Content.Map + (newContent, newComponents, newMessages) = try await content.externallyDereferenced(with: Loader.self) + + newContext = .querystring(required: required, content: newContent) } var newParameter = self - newParameter.schemaOrContent = newSchemaOrContent + newParameter.context = newContext return (newParameter, newComponents, newMessages) } } + +fileprivate func externallyDereference( + schemaOrContent: Either, + with loader: Loader.Type +) async throws -> (Either, OpenAPI.Components, [Loader.Message]) { + switch schemaOrContent { + case .a(let schemaContext): + let (context, components, messages) = try await schemaContext.externallyDereferenced(with: loader) + return (.a(context), components, messages) + case .b(let contentMap): + let (map, components, messages) = try await contentMap.externallyDereferenced(with: loader) + return (.b(map), components, messages) + } +} diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index 754a32ad3..1d0332a85 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -25,22 +25,6 @@ extension OpenAPI { /// if unspecified and only gets encoded if true. public var deprecated: Bool // default is false - /// OpenAPI Spec "content" or "schema" properties. - /// - /// You can access the schema context (if it is in use for - /// this parameter) with `schemaOrContent.schemaContextValue`. - /// The schema context contains lots of information detailed in the - /// OpenAPI specification under the **Parameter Object** section. - /// - /// You can directly access the underlying `JSONSchema` with - /// `schemaOrContent.schemaValue`. If the schema is a reference - /// instead of an inline value, `schemaOrContent.schemaReference` - /// will get you the reference. - /// - /// You can access the content map (if it is in use for - /// this parameter) with `schemaOrContent.contentValue`. - public var schemaOrContent: Either - /// Dictionary of vendor extensions. /// /// These should be of the form: @@ -58,88 +42,45 @@ extension OpenAPI { /// parameter. public var location: Context.Location { return context.location } - /// Create a parameter with an `Either`. - public init( - name: String, - context: Context, - schemaOrContent: Either, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = schemaOrContent - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions - } - - /// Create a parameter with a `SchemaContext`. - public init( - name: String, - context: Context, - schema: SchemaContext, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(schema) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions - } - - /// Create a parameter with a `JSONSchema` and the default - /// `style` for the given `Context`. - public init( - name: String, - context: Context, - schema: JSONSchema, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(SchemaContext(schema, style: .default(for: context))) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions - } - - /// Create a parameter with a reference to a `JSONSchema` - /// and the default `style` for the given `Context`. - public init( - name: String, - context: Context, - schemaReference: OpenAPI.Reference, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(SchemaContext(schemaReference: schemaReference, style: .default(for: context))) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions + /// OpenAPI Spec "content" or "schema" properties. + /// + /// You can access the schema context (if it is in use for + /// this parameter) with `schemaOrContent.schemaContextValue`. + /// The schema context contains lots of information detailed in the + /// OpenAPI specification under the **Parameter Object** section. + /// + /// You can directly access the underlying `JSONSchema` with + /// `schemaOrContent.schemaValue`. If the schema is a reference + /// instead of an inline value, `schemaOrContent.schemaReference` + /// will get you the reference. + /// + /// You can access the content map (if it is in use for + /// this parameter) with `schemaOrContent.contentValue`. + public var schemaOrContent: Either { + switch context { + case .query(required: _, allowEmptyValue: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .header(required: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .path(schemaOrContent: let schemaOrContent): + return schemaOrContent + case .cookie(required: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .querystring(required: _, content: let content): + return .content(content) + } } - /// Create a parameter with a `Content.Map`. + /// Create a parameter. public init( name: String, context: Context, - content: OpenAPI.Content.Map, description: String? = nil, deprecated: Bool = false, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name self.context = context - self.schemaOrContent = .init(content) self.description = description self.deprecated = deprecated self.vendorExtensions = vendorExtensions @@ -157,6 +98,328 @@ extension OpenAPI.Parameter { public typealias Array = [Either, OpenAPI.Parameter>] } +// MARK: Convenience constructors +extension OpenAPI.Parameter { + public static func cookie( + name: String, + required: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie(required: required, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .cookie))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header(required: required, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .header))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(content: content), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schema: schema), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .path)))), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .query))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func querystring( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .querystring(content: content), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } +} + extension OpenAPI.Parameter { /// A parameter identity is just a hashable struct /// containing exactly the things that differentiate @@ -173,11 +436,10 @@ extension OpenAPI.Parameter { // OpenAPI.PathItem.Array.Element => extension Either where A == OpenAPI.Reference, B == OpenAPI.Parameter { - /// Construct a parameter using a `JSONSchema`. + /// Construct a parameter. public static func parameter( name: String, context: OpenAPI.Parameter.Context, - schema: JSONSchema, description: String? = nil, deprecated: Bool = false, vendorExtensions: [String: AnyCodable] = [:] @@ -186,28 +448,6 @@ extension Either where A == OpenAPI.Reference, B == OpenAPI.P .init( name: name, context: context, - schema: schema, - description: description, - deprecated: deprecated, - vendorExtensions: vendorExtensions - ) - ) - } - - /// Construct a parameter using a `Content.Map`. - public static func parameter( - name: String, - context: OpenAPI.Parameter.Context, - content: OpenAPI.Content.Map, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) -> Self { - return .b( - .init( - name: name, - context: context, - content: content, description: description, deprecated: deprecated, vendorExtensions: vendorExtensions @@ -238,23 +478,23 @@ extension OpenAPI.Parameter: Encodable { let required: Bool let location: Context.Location switch context { - case .query(required: let req, allowEmptyValue: let allowEmptyValue): + case .query(required: let req, allowEmptyValue: let allowEmptyValue, schemaOrContent: _): required = req location = .query if allowEmptyValue { try container.encode(allowEmptyValue, forKey: .allowEmptyValue) } - case .header(required: let req): + case .header(required: let req, schemaOrContent: _): required = req location = .header - case .path: + case .path(schemaOrContent: _): required = true location = .path - case .cookie(required: let req): + case .cookie(required: let req, schemaOrContent: _): required = req location = .cookie - case .querystring(required: let req): + case .querystring(required: let req, content: _): required = req location = .querystring } @@ -266,7 +506,7 @@ extension OpenAPI.Parameter: Encodable { switch schemaOrContent { case .a(let schema): - try schema.encode(to: encoder, for: context) + try schema.encode(to: encoder, for: location) case .b(let contentMap): try container.encode(contentMap, forKey: .content) } @@ -293,44 +533,16 @@ extension OpenAPI.Parameter: Decodable { let required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? false let location = try container.decode(Context.Location.self, forKey: .parameterLocation) - switch location { - case .query: - let allowEmptyValue = try container.decodeIfPresent(Bool.self, forKey: .allowEmptyValue) ?? false - context = .query(required: required, allowEmptyValue: allowEmptyValue) - case .header: - context = .header(required: required) - case .path: - if !required { - throw GenericError( - subjectName: name, - details: "positional path parameters must be explicitly set to required", - codingPath: decoder.codingPath - ) - } - context = .path - case .cookie: - context = .cookie(required: required) - case .querystring: - context = .querystring(required: required) - } - let maybeContent = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) let maybeSchema: SchemaContext? if container.contains(.schema) { - maybeSchema = try SchemaContext(from: decoder, for: context) + maybeSchema = try SchemaContext(from: decoder, for: location) } else { maybeSchema = nil } - if location == .querystring && maybeSchema != nil { - throw GenericError( - subjectName: name, - details: "`schema` and `style` are disallowed for `querystring` parameters", - codingPath: decoder.codingPath - ) - } - + let schemaOrContent: Either switch (maybeContent, maybeSchema) { case (let content?, nil): schemaOrContent = .init(content) @@ -350,6 +562,34 @@ extension OpenAPI.Parameter: Decodable { ) } + switch location { + case .query: + let allowEmptyValue = try container.decodeIfPresent(Bool.self, forKey: .allowEmptyValue) ?? false + context = .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent) + case .header: + context = .header(required: required, schemaOrContent: schemaOrContent) + case .path: + if !required { + throw GenericError( + subjectName: name, + details: "positional path parameters must be explicitly set to required", + codingPath: decoder.codingPath + ) + } + context = .path(schemaOrContent: schemaOrContent) + case .cookie: + context = .cookie(required: required, schemaOrContent: schemaOrContent) + case .querystring: + guard case .b(let content) = schemaOrContent else { + throw GenericError( + subjectName: name, + details: "`schema` and `style` are disallowed for `querystring` parameters", + codingPath: decoder.codingPath + ) + } + context = .querystring(required: required, content: content) + } + description = try container.decodeIfPresent(String.self, forKey: .description) deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false diff --git a/Sources/OpenAPIKit/Parameter/ParameterContext.swift b/Sources/OpenAPIKit/Parameter/ParameterContext.swift index e4530d238..5b8ab5f5e 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterContext.swift @@ -17,28 +17,78 @@ extension OpenAPI.Parameter { /// `required: true` to the context construction. /// Path parameters are always required. public enum Context: Equatable, Sendable { - case query(required: Bool, allowEmptyValue: Bool) - case header(required: Bool) - case path - case cookie(required: Bool) - case querystring(required: Bool) + case query(required: Bool, allowEmptyValue: Bool, schemaOrContent: Either) + case header(required: Bool, schemaOrContent: Either) + case path(schemaOrContent: Either) + case cookie(required: Bool, schemaOrContent: Either) + case querystring(required: Bool, content: OpenAPI.Content.Map) - public static func query(required: Bool) -> Context { return .query(required: required, allowEmptyValue: false) } + /// A query parameter that does not allow empty values. + public static func query( + required: Bool = false, + schemaOrContent: Either + ) -> Context { return .query(required: required, allowEmptyValue: false, schemaOrContent: schemaOrContent) } - public static func query(allowEmptyValue: Bool) -> Context { return .query(required: false, allowEmptyValue: allowEmptyValue) } + /// A query parameter that is not required. + public static func query( + allowEmptyValue: Bool, + schemaOrContent: Either + ) -> Context { return .query(required: false, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent) } - /// An optional query parameter that does not allow - /// empty values. - public static var query: Context { return .query(required: false, allowEmptyValue: false) } + public static func query( + required: Bool = false, + allowEmptyValue: Bool = false, + schema: JSONSchema + ) -> Context { return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: .schema(.init(schema, style: .default(for: .query)))) } + + public static func query( + required: Bool = false, + allowEmptyValue: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: .content(content)) } /// An optional header parameter. - public static var header: Context { return .header(required: false) } + public static func header( + schemaOrContent: Either + ) -> Context { return .header(required: false, schemaOrContent: schemaOrContent) } + + public static func header( + required: Bool = false, + schema: JSONSchema + ) -> Context { return .header(required: required, schemaOrContent: .schema(.init(schema, style: .default(for: .header)))) } + + public static func header( + required: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .header(required: required, schemaOrContent: .content(content)) } /// An optional cookie parameter. - public static var cookie: Context { return .cookie(required: false) } + public static func cookie( + schemaOrContent: Either + ) -> Context { return .cookie(required: false, schemaOrContent: schemaOrContent) } + + public static func cookie( + required: Bool = false, + schema: JSONSchema + ) -> Context { return .cookie(required: required, schemaOrContent: .schema(.init(schema, style: .default(for: .cookie)))) } + + public static func cookie( + required: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .cookie(required: required, schemaOrContent: .content(content)) } + + public static func path( + schema: JSONSchema + ) -> Context { return .path(schemaOrContent: .schema(.init(schema, style: .default(for: .path)))) } + + public static func path( + content: OpenAPI.Content.Map + ) -> Context { return .path(schemaOrContent: .content(content)) } /// An optional querystring parameter. - public static var querystring: Context { return .querystring(required: false) } + public static func querystring( + content: OpenAPI.Content.Map + ) -> Context { return .querystring(required: false, content: content) } public var inQuery: Bool { guard case .query = self else { @@ -54,7 +104,12 @@ extension OpenAPI.Parameter { return true } - public var inPath: Bool { return self == .path } + public var inPath: Bool { + guard case .path = self else { + return false + } + return true + } public var inCookie: Bool { guard case .cookie = self else { @@ -72,12 +127,12 @@ extension OpenAPI.Parameter { public var required: Bool { switch self { - case .query(required: let required, allowEmptyValue: _), - .header(required: let required), - .cookie(required: let required), - .querystring(required: let required): + case .query(required: let required, allowEmptyValue: _, schemaOrContent: _), + .header(required: let required, schemaOrContent: _), + .cookie(required: let required, schemaOrContent: _), + .querystring(required: let required, content: _): return required - case .path: + case .path(schemaOrContent: _): return true } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index 2928db7d2..c3ef75243 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -127,13 +127,52 @@ extension OpenAPI.Parameter { } } +extension OpenAPI.Parameter.SchemaContext { + public static func schema(_ schema: JSONSchema, + style: Style, + explode: Bool, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schema, style: style, explode: explode, allowReserved: allowReserved, examples: examples) + } + + public static func schema(_ schema: JSONSchema, + style: Style, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schema, style: style, allowReserved: allowReserved, examples: examples) + } + + public static func schemaReference(_ reference: OpenAPI.Reference, + style: Style, + explode: Bool, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schemaReference: reference, + style: style, + explode: explode, + allowReserved: allowReserved, + examples: examples) + } + + public static func schemaReference(_ reference: OpenAPI.Reference, + style: Style, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schemaReference: reference, + style: style, + allowReserved: allowReserved, + examples: examples) + } +} + extension OpenAPI.Parameter.SchemaContext.Style { /// Get the default `Style` for the given location /// per the OpenAPI Specification. /// /// See the `style` fixed field under /// [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). - public static func `default`(for location: OpenAPI.Parameter.Context) -> Self { + public static func `default`(for location: OpenAPI.Parameter.Context.Location) -> Self { switch location { case .query: return .form @@ -148,6 +187,26 @@ extension OpenAPI.Parameter.SchemaContext.Style { } } + /// Get the default `Style` for the given context + /// per the OpenAPI Specification. + /// + /// See the `style` fixed field under + /// [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). + public static func `default`(for context: OpenAPI.Parameter.Context) -> Self { + switch context { + case .query: + return .form + case .cookie: + return .form + case .path: + return .simple + case .header: + return .simple + case .querystring: + return .simple + } + } + internal var defaultExplode: Bool { switch self { case .form: @@ -173,7 +232,7 @@ extension OpenAPI.Parameter.SchemaContext { } extension OpenAPI.Parameter.SchemaContext { - public func encode(to encoder: Encoder, for location: OpenAPI.Parameter.Context) throws { + public func encode(to encoder: Encoder, for location: OpenAPI.Parameter.Context.Location) throws { var container = encoder.container(keyedBy: CodingKeys.self) if style != Style.default(for: location) { @@ -199,7 +258,7 @@ extension OpenAPI.Parameter.SchemaContext { } extension OpenAPI.Parameter.SchemaContext { - public init(from decoder: Decoder, for location: OpenAPI.Parameter.Context) throws { + public init(from decoder: Decoder, for location: OpenAPI.Parameter.Context.Location) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schema = try container.decode(Either, JSONSchema>.self, forKey: .schema) diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 92928524a..06b210672 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -100,16 +100,8 @@ extension OpenAPIKit30.OpenAPI.Server: To31 { extension OpenAPIKit30.OpenAPI.Header: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Header { - let newSchemaOrContent: Either - switch schemaOrContent { - case .a(let context): - newSchemaOrContent = .a(context.to31()) - case .b(let contentMap): - newSchemaOrContent = .b(contentMap.mapValues { $0.to31() }) - } - - return OpenAPIKit.OpenAPI.Header( - schemaOrContent: newSchemaOrContent, + OpenAPIKit.OpenAPI.Header( + schemaOrContent: schemaOrContent.to31(), description: description, required: `required`, deprecated: deprecated, @@ -118,17 +110,28 @@ extension OpenAPIKit30.OpenAPI.Header: To31 { } } -extension OpenAPIKit30.OpenAPI.Parameter.Context: To31 { - fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter.Context { +extension Either where A == OpenAPIKit30.OpenAPI.Parameter.SchemaContext, B == OpenAPIKit30.OpenAPI.Content.Map { + fileprivate func to31() -> Either { + switch self { + case .a(let context): + .a(context.to31()) + case .b(let contentMap): + .b(contentMap.mapValues { $0.to31() }) + } + } +} + +extension OpenAPIKit30.OpenAPI.Parameter.Context { + fileprivate func to31(with schemaOrContent: Either) -> OpenAPIKit.OpenAPI.Parameter.Context { switch self { case .query(required: let required, allowEmptyValue: let allowEmptyValue): - return .query(required: required, allowEmptyValue: allowEmptyValue) + return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent.to31()) case .header(required: let required): - return .header(required: required) + return .header(required: required, schemaOrContent: schemaOrContent.to31()) case .path: - return .path + return .path(schemaOrContent: schemaOrContent.to31()) case .cookie(required: let required): - return .cookie(required: required) + return .cookie(required: required, schemaOrContent: schemaOrContent.to31()) } } } @@ -242,18 +245,9 @@ extension OpenAPIKit30.OpenAPI.Content: To31 { extension OpenAPIKit30.OpenAPI.Parameter: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter { - let newSchemaOrContent: Either - switch schemaOrContent { - case .a(let context): - newSchemaOrContent = .a(context.to31()) - case .b(let contentMap): - newSchemaOrContent = .b(contentMap.mapValues { $0.to31() }) - } - return OpenAPIKit.OpenAPI.Parameter( name: name, - context: context.to31(), - schemaOrContent: newSchemaOrContent, + context: context.to31(with: schemaOrContent), description: description, deprecated: deprecated, vendorExtensions: vendorExtensions diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 100430d22..4f73d05d2 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -996,14 +996,14 @@ fileprivate func assertEqualNewToOld(_ newParam: OpenAPIKit.OpenAPI.Parameter, _ fileprivate func assertEqualNewToOld(_ newParamContext: OpenAPIKit.OpenAPI.Parameter.Context, _ oldParamContext: OpenAPIKit30.OpenAPI.Parameter.Context) { switch (newParamContext, oldParamContext) { - case (.query(required: let req, allowEmptyValue: let empty), .query(required: let req2, allowEmptyValue: let empty2)): + case (.query(required: let req, allowEmptyValue: let empty, schemaOrContent: _), .query(required: let req2, allowEmptyValue: let empty2)): XCTAssertEqual(req, req2) XCTAssertEqual(empty, empty2) - case (.header(required: let req), .header(required: let req2)): + case (.header(required: let req, schemaOrContent: _), .header(required: let req2)): XCTAssertEqual(req, req2) case (.path, .path): break - case (.cookie(required: let req), .cookie(required: let req2)): + case (.cookie(required: let req, schemaOrContent: _), .cookie(required: let req2)): XCTAssertEqual(req, req2) default: XCTFail("Parameter contexts are not equal. \(newParamContext) / \(oldParamContext)") @@ -1137,6 +1137,7 @@ fileprivate func assertEqualNewToOld(_ newSchema: OpenAPIKit.JSONSchema, _ oldSc case .number(let coreContext, let numericContext): let newNumericContext = try XCTUnwrap(newSchema.numberContext) // TODO: compare number contexts + // try assertEqualNewToOld(newNumericContext, numericContext) try assertEqualNewToOld(newCoreContext, coreContext) case .integer(let coreContext, let integerContext): diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 0cbf875b7..8f603962b 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -99,7 +99,7 @@ final class ComponentsTests: XCTestCase { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hello", context: .query, schema: .string) + "three": .init(name: "hello", context: .query(schema: .string)) ], examples: [ "four": .init(value: .init(URL(string: "hello.com/hello")!)) @@ -139,7 +139,7 @@ final class ComponentsTests: XCTestCase { XCTAssertEqual(components[ref1], .string) XCTAssertEqual(components[ref2], .init(description: "hello", content: [:])) - XCTAssertEqual(components[ref3], .init(name: "hello", context: .query, schema: .string)) + XCTAssertEqual(components[ref3], .init(name: "hello", context: .query(schema: .string))) XCTAssertEqual(components[ref4], .init(value: .init(URL(string: "hello.com/hello")!))) XCTAssertEqual(components[ref5], .init(content: [:])) XCTAssertEqual(components[ref6], .init(schema: .string)) @@ -284,7 +284,7 @@ extension ComponentsTests { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hi", context: .query, content: [:]) + "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ "four": .init(value: .init(URL(string: "http://address.com")!)) @@ -506,7 +506,7 @@ extension ComponentsTests { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hi", context: .query, content: [:]) + "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ "four": .init(value: .init(URL(string: "http://address.com")!)) diff --git a/Tests/OpenAPIKitTests/EaseOfUseTests.swift b/Tests/OpenAPIKitTests/EaseOfUseTests.swift index 45854c598..d8ecabe81 100644 --- a/Tests/OpenAPIKitTests/EaseOfUseTests.swift +++ b/Tests/OpenAPIKitTests/EaseOfUseTests.swift @@ -39,8 +39,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [ .parameter( name: "param", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: .init( @@ -51,12 +50,14 @@ final class DeclarativeEaseOfUseTests: XCTestCase { .reference(.component( named: "filter")), .parameter( name: "Content-Type", - context: .header(required: false), - schema: .string( - allowedValues: [ - .init(OpenAPI.ContentType.json.rawValue), - .init(OpenAPI.ContentType.txt.rawValue) - ] + context: .header( + required: false, + schema: .string( + allowedValues: [ + .init(OpenAPI.ContentType.json.rawValue), + .init(OpenAPI.ContentType.txt.rawValue) + ] + ) ) ) ], @@ -116,16 +117,18 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [ "filter": .init( name: "filter", - context: .query(required: false), - schema: .init( - .object( - properties: [ - "size": .integer, - "shape": .string(allowedValues: [ "round", "square" ]) - ] - ), - style: .deepObject, - explode: true + context: .query( + required: false, + schemaOrContent: .schema(.init( + .object( + properties: [ + "size": .integer, + "shape": .string(allowedValues: [ "round", "square" ]) + ] + ), + style: .deepObject, + explode: true + )) ) ) ] @@ -168,12 +171,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { .reference(.component( named: "filter")), .parameter( name: "Content-Type", - context: .header(required: false), - schema: .string( - allowedValues: [ - .init(OpenAPI.ContentType.json.rawValue), - .init(OpenAPI.ContentType.txt.rawValue) - ] + context: .header(required: false, + schema: .string( + allowedValues: [ + .init(OpenAPI.ContentType.json.rawValue), + .init(OpenAPI.ContentType.txt.rawValue) + ] + ) ) ) ], @@ -232,8 +236,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [ .parameter( name: "param", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: testSHOW_endpoint, @@ -245,10 +248,10 @@ final class DeclarativeEaseOfUseTests: XCTestCase { "string_schema": .string ], parameters: [ - "filter": .init( + "filter": .query( name: "filter", - context: .query(required: false), - schema: .init( + required: false, + schemaOrContent: .schema(.init( .object( properties: [ "size": .integer, @@ -258,7 +261,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { style: .deepObject, explode: true ) - ) + )) ] ) @@ -517,8 +520,7 @@ fileprivate let testDocument = OpenAPI.Document( parameters: [ .parameter( name: "id", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: OpenAPI.Operation( diff --git a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift index 925885bcc..b192b1bbe 100644 --- a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift +++ b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift @@ -84,7 +84,7 @@ final class OpenAPIReferenceTests: XCTestCase { "hello": .init(description: "description") ], parameters: [ - "hello": .init(name: "name", context: .path, content: [:], description: "description") + "hello": .path(name: "name", content: [:], description: "description") ], examples: [ "hello": .init(summary: "summary", description: "description", value: .b("")) diff --git a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift index 611528269..1409cfa74 100644 --- a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift @@ -25,8 +25,7 @@ final class DereferencedOperationTests: XCTestCase { parameters: [ .parameter( name: "test", - context: .header, - schema: .string + context: .header(schema: .string) ) ], requestBody: OpenAPI.Request(content: [.json: .init(schema: .string)]), @@ -44,9 +43,8 @@ final class DereferencedOperationTests: XCTestCase { func test_parameterReference() throws { let components = OpenAPI.Components( parameters: [ - "test": .init( + "test": .header( name: "test", - context: .header, schema: .string ) ] @@ -59,9 +57,8 @@ final class DereferencedOperationTests: XCTestCase { ).dereferenced(in: components) XCTAssertEqual( t1.parameters.first?.underlyingParameter, - .init( + .header( name: "test", - context: .header, schema: .string, vendorExtensions: ["x-component-name": "test"] ) diff --git a/Tests/OpenAPIKitTests/Operation/OperationTests.swift b/Tests/OpenAPIKitTests/Operation/OperationTests.swift index a5bea626f..6cd175652 100644 --- a/Tests/OpenAPIKitTests/Operation/OperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/OperationTests.swift @@ -23,7 +23,7 @@ final class OperationTests: XCTestCase { description: "description", externalDocs: .init(url: URL(string: "https://google.com")!), operationId: "123", - parameters: [.parameter(name: "hi", context: .query, schema: .string)], + parameters: [.parameter(name: "hi", context: .query(schema: .string))], requestBody: .init(content: [:]), responses: [:], callbacks: [:], diff --git a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift index 6cd40b9ef..dcac3c9bd 100644 --- a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift +++ b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift @@ -205,14 +205,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header, schema: .string)], + parameters: [.parameter(name: "one", context: .header(schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "two", context: .query, schema: .string)], + parameters: [.parameter(name: "two", context: .query(schema: .string))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, @@ -243,14 +243,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header, schema: .string)], + parameters: [.parameter(name: "one", context: .header(schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "one", context: .header, schema: .integer)], + parameters: [.parameter(name: "one", context: .header(schema: .integer))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, @@ -445,14 +445,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header(required: true), schema: .string)], + parameters: [.parameter(name: "one", context: .header(required: true, schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "two", context: .query, schema: .string)], + parameters: [.parameter(name: "two", context: .query(schema: .string))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift index 126d3acc0..559491a89 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift @@ -10,14 +10,13 @@ import OpenAPIKit final class DereferencedParameterTests: XCTestCase { func test_inlineSchemaParameter() throws { - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, schema: .string ).dereferenced(in: .noComponents) XCTAssertEqual(t1.name, "test") - XCTAssertEqual(t1.context, .header) + XCTAssertEqual(t1.context, .header(schema: .string)) XCTAssertEqual( t1.schemaOrContent.schemaContextValue, try OpenAPI.Parameter.SchemaContext.header(.string).dereferenced(in: .noComponents) @@ -25,16 +24,17 @@ final class DereferencedParameterTests: XCTestCase { XCTAssertEqual(t1.schemaOrContent.schemaValue?.jsonSchema, .string) XCTAssertNil(t1.schemaOrContent.contentValue) - let t2 = try OpenAPI.Parameter( + let t2 = try OpenAPI.Parameter.path( name: "test2", - context: .path, content: [ .anyText: .init(schema: .string) ] ).dereferenced(in: .noComponents) XCTAssertEqual(t2.name, "test2") - XCTAssertEqual(t2.context, .path) + XCTAssertEqual(t2.context, .path(content: [ + .anyText: .init(schema: .string) + ])) XCTAssertEqual( t2.schemaOrContent.contentValue, [ @@ -46,9 +46,8 @@ final class DereferencedParameterTests: XCTestCase { } func test_inlineContentParameter() throws { - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, content: [ .json: .init(schema: .string) ] @@ -63,9 +62,8 @@ final class DereferencedParameterTests: XCTestCase { "test": .string ] ) - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, schemaReference: .component(named: "test") ).dereferenced(in: components) @@ -81,9 +79,8 @@ final class DereferencedParameterTests: XCTestCase { "test": .string ] ) - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, content: [.json: .init(schemaReference: .component(named: "test"))] ).dereferenced(in: components) diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift index 1e75cd68f..ad93d2b0d 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift @@ -12,24 +12,24 @@ final class ParameterContextTests: XCTestCase { typealias Context = OpenAPI.Parameter.Context func test_query() { - let t1: Context = .query - XCTAssertEqual(t1, Context.query(required: false, allowEmptyValue: false)) + let t1: Context = .query(schema: .string) + XCTAssertEqual(t1, Context.query(required: false, allowEmptyValue: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inQuery) XCTAssertFalse(t1.inHeader) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inCookie) - let t2: Context = .query(allowEmptyValue: true) - XCTAssertEqual(t2, Context.query(required: false, allowEmptyValue: true)) + let t2: Context = .query(allowEmptyValue: true, schema: .string) + XCTAssertEqual(t2, Context.query(required: false, allowEmptyValue: true, schema: .string)) XCTAssertFalse(t2.required) XCTAssertTrue(t2.inQuery) XCTAssertFalse(t2.inHeader) XCTAssertFalse(t2.inPath) XCTAssertFalse(t2.inCookie) - let t3: Context = .query(required: true) - XCTAssertEqual(t3, Context.query(required: true, allowEmptyValue: false)) + let t3: Context = .query(required: true, schema: .string) + XCTAssertEqual(t3, Context.query(required: true, allowEmptyValue: false, schema: .string)) XCTAssertTrue(t3.required) XCTAssertTrue(t3.inQuery) XCTAssertFalse(t3.inHeader) @@ -38,31 +38,31 @@ final class ParameterContextTests: XCTestCase { } func test_header() { - let t1: Context = .header - XCTAssertEqual(t1, Context.header(required: false)) + let t1: Context = .header(schema: .string) + XCTAssertEqual(t1, Context.header(required: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inHeader) XCTAssertFalse(t1.inQuery) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inCookie) - XCTAssertTrue(Context.header(required: true).required) + XCTAssertTrue(Context.header(required: true, schema: .string).required) } func test_cookie() { - let t1: Context = .cookie - XCTAssertEqual(t1, Context.cookie(required: false)) + let t1: Context = .cookie(schema: .string) + XCTAssertEqual(t1, Context.cookie(required: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inCookie) XCTAssertFalse(t1.inQuery) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inHeader) - XCTAssertTrue(Context.cookie(required: true).required) + XCTAssertTrue(Context.cookie(required: true, schema: .string).required) } func test_path() { - let t1: Context = .path + let t1: Context = .path(schema: .string) XCTAssertTrue(t1.required) XCTAssertTrue(t1.inPath) XCTAssertFalse(t1.inQuery) @@ -71,10 +71,10 @@ final class ParameterContextTests: XCTestCase { } func test_location() { - let t1: Context = .cookie - let t2: Context = .header - let t3: Context = .path - let t4: Context = .query + let t1: Context = .cookie(schema: .string) + let t2: Context = .header(schema: .string) + let t3: Context = .path(schema: .string) + let t4: Context = .query(schema: .string) XCTAssertEqual(t1.location, .cookie) XCTAssertEqual(t2.location, .header) diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift index 35ea9da84..5300f8f3b 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift @@ -531,7 +531,7 @@ fileprivate struct SchemaWrapper: Codable { let location: TestLocation let schema: OpenAPI.Parameter.SchemaContext - init(location: OpenAPI.Parameter.Context, schema: OpenAPI.Parameter.SchemaContext) { + init(location: OpenAPI.Parameter.Context.Location, schema: OpenAPI.Parameter.SchemaContext) { self.location = .init(location) self.schema = schema } @@ -546,22 +546,25 @@ fileprivate struct SchemaWrapper: Codable { case header case path case cookie + case querystring - var paramLoc: OpenAPI.Parameter.Context { + var paramLoc: OpenAPI.Parameter.Context.Location { switch self { - case .query: return .query - case .header: return .header - case .path: return .path - case .cookie: return .cookie + case .query: .query + case .header: .header + case .path: .path + case .cookie: .cookie + case .querystring: .querystring } } - init(_ paramLoc: OpenAPI.Parameter.Context) { + init(_ paramLoc: OpenAPI.Parameter.Context.Location) { switch paramLoc { case .query: self = .query case .header: self = .header case .path: self = .path case .cookie: self = .cookie + case .querystring: self = .querystring } } } diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift index 6e86c40aa..bb8bd8035 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift @@ -10,18 +10,18 @@ import OpenAPIKit final class ParameterTests: XCTestCase { func test_initialize() { - let t1 = OpenAPI.Parameter( + let t1 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schemaOrContent: .init([.json: OpenAPI.Content(schema: .string)]), description: "hi", deprecated: true ) XCTAssertTrue(t1.required) - let t2 = OpenAPI.Parameter( + let t2 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schemaOrContent: .content([.json: OpenAPI.Content(schema: .string)]), description: "hi", deprecated: true @@ -29,46 +29,21 @@ final class ParameterTests: XCTestCase { XCTAssertTrue(t2.deprecated) XCTAssertEqual(t1, t2) - let t4 = OpenAPI.Parameter( + let t6 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: false), - schema: .init(.string, style: .default(for: .cookie)), - description: "hi", - deprecated: false - ) - XCTAssertFalse(t4.required) - - let t5 = OpenAPI.Parameter( - name: "hello", - context: .cookie, - schema: .string - ) - XCTAssertFalse(t5.deprecated) - - let t6 = OpenAPI.Parameter( - name: "hello", - context: .cookie, schemaOrContent: .schema(.init(.string, style: .default(for: .cookie))) ) - XCTAssertEqual(t5, t6) + XCTAssertFalse(t6.required) - let _ = OpenAPI.Parameter( + let _ = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaReference: .component( named: "hello") ) - - let _ = OpenAPI.Parameter( - name: "hello", - context: .cookie, - content: [.json: OpenAPI.Content(schema: .string)] - ) } func test_schemaAccess() { - let t1 = OpenAPI.Parameter( + let t1 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaOrContent: .schema(.init(.string, style: .default(for: .cookie))) ) @@ -79,9 +54,8 @@ final class ParameterTests: XCTestCase { XCTAssertEqual(t1.schemaOrContent.schemaContextValue, .init(.string, style: .default(for: .cookie))) XCTAssertEqual(t1.schemaOrContent.schemaContextValue?.schema.schemaValue, t1.schemaOrContent.schemaValue) - let t2 = OpenAPI.Parameter( + let t2 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaReference: .component( named: "hello") ) @@ -91,9 +65,8 @@ final class ParameterTests: XCTestCase { XCTAssertEqual(t2.schemaOrContent.schemaReference, .component( named: "hello")) XCTAssertEqual(t2.schemaOrContent.schemaContextValue?.schema.reference, t2.schemaOrContent.schemaReference) - let t3 = OpenAPI.Parameter( + let t3 = OpenAPI.Parameter.path( name: "hello", - context: .path, content: [:] ) @@ -105,10 +78,10 @@ final class ParameterTests: XCTestCase { func test_parameterArray() { let t1: OpenAPI.Parameter.Array = [ - .parameter(OpenAPI.Parameter(name: "hello", context: .cookie, schema: .string)), - .parameter(name: "hello", context: .cookie, schema: .string), - .parameter(OpenAPI.Parameter(name: "hello", context: .cookie, content: [.json: OpenAPI.Content(schema: .string)])), - .parameter(name: "hello", context: .cookie, content: [.json: OpenAPI.Content(schema: .string)]), + .parameter(OpenAPI.Parameter.cookie(name: "hello", schema: .string)), + .parameter(name: "hello", context: .cookie(schema: .string)), + .parameter(OpenAPI.Parameter.cookie(name: "hello", content: [.json: OpenAPI.Content(schema: .string)])), + .parameter(name: "hello", context: .cookie(content: [.json: OpenAPI.Content(schema: .string)])), .reference(.component( named: "hello")) ] @@ -119,7 +92,7 @@ final class ParameterTests: XCTestCase { XCTAssertNotEqual(t1[4], t1[2]) XCTAssertNotEqual(t1[4], t1[3]) - XCTAssertEqual(t1[0].parameterValue, OpenAPI.Parameter(name: "hello", context: .cookie, schema: .string)) + XCTAssertEqual(t1[0].parameterValue, OpenAPI.Parameter.cookie(name: "hello", schema: .string)) XCTAssertEqual(t1[4].reference, .component( named: "hello")) } } @@ -127,9 +100,8 @@ final class ParameterTests: XCTestCase { // MARK: - Codable Tests extension ParameterTests { func test_minimalContent_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, content: [ .json: .init(schema: .string)] ) @@ -175,9 +147,8 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, content: [ .json: .init(schema: .string)] ) ) @@ -185,9 +156,8 @@ extension ParameterTests { } func test_minimalSchema_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string ) @@ -225,18 +195,16 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string ) ) } func test_queryParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query, schema: .string ) @@ -273,9 +241,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .query) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query, schema: .string ) ) @@ -287,9 +254,9 @@ extension ParameterTests { } func test_queryParamAllowEmpty_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query(allowEmptyValue: true), + allowEmptyValue: true, schema: .string ) @@ -327,18 +294,18 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query(allowEmptyValue: true), + allowEmptyValue: true, schema: .string ) ) } func test_requiredQueryParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query(required: true), + required: true, schema: .string ) @@ -376,18 +343,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query(required: true), + required: true, schema: .string ) ) } func test_headerParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header, schema: .string ) @@ -424,18 +390,17 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header, schema: .string ) ) } func test_requiredHeaderParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), + required: true, schema: .string ) @@ -473,18 +438,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), + required: true, schema: .string ) ) } func test_cookieParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schema: .string ) @@ -521,18 +485,17 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .cookie) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schema: .string ) ) } func test_requiredCookieParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schema: .string ) @@ -570,18 +533,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schema: .string ) ) } func test_deprecated_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, deprecated: true ) @@ -622,9 +584,8 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, deprecated: true ) @@ -632,9 +593,8 @@ extension ParameterTests { } func test_description_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world" ) @@ -676,9 +636,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .path) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world" ) @@ -686,13 +645,15 @@ extension ParameterTests { } func test_example_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - example: "hello string" + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + example: "hello string" + ) ) ) @@ -733,29 +694,33 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - example: "hello string" + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + example: "hello string" + ) ) ) ) } func test_examples_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - allowReserved: true, - examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) - ] + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + allowReserved: true, + examples: [ + "test": .example(value: .init(URL(string: "http://website.com")!)) + ] + ) ) ) @@ -806,25 +771,26 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - allowReserved: true, - examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) - ] + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + allowReserved: true, + examples: [ + "test": .example(value: .init(URL(string: "http://website.com")!)) + ] + ) ) ) ) } func test_vendorExtension_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world", vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] @@ -875,9 +841,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .path) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world", vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] diff --git a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift index 5ca4c9448..3a48d3f33 100644 --- a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift @@ -34,7 +34,7 @@ final class DereferencedPathItemTests: XCTestCase { func test_inlinedOperationsAndParameters() throws { let t1 = try OpenAPI.PathItem( parameters: [ - .parameter(name: "param", context: .header, schema: .string) + .parameter(name: "param", context: .header(schema: .string)) ], get: .init(tags: "get op", responses: [:]), put: .init(tags: "put op", responses: [:]), @@ -67,7 +67,7 @@ final class DereferencedPathItemTests: XCTestCase { func test_referencedParameter() throws { let components = OpenAPI.Components( parameters: [ - "test": .init(name: "param", context: .header, schema: .string) + "test": .init(name: "param", context: .header(schema: .string)) ] ) let t1 = try OpenAPI.PathItem( diff --git a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift index 13087fbbd..367aff363 100644 --- a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift @@ -46,7 +46,7 @@ final class PathItemTests: XCTestCase { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], get: op, put: op, post: op, @@ -159,7 +159,7 @@ final class PathItemTests: XCTestCase { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], get: op, put: op, post: op, @@ -240,7 +240,7 @@ extension PathItemTests { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) @@ -310,7 +310,7 @@ extension PathItemTests { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) ) diff --git a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift index 246fe1cf2..5b611aa9b 100644 --- a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift @@ -18,7 +18,7 @@ final class ResolvedRouteTests: XCTestCase { summary: "routeSummary", description: "routeDescription", servers: [], - parameters: [.parameter(name: "id", context: .path, schema: .integer)], + parameters: [.parameter(name: "id", context: .path(schema: .integer))], get: .init( summary: "get", responses: [200: .response(description: "hello world")] diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index a7a07cc51..25a761ae9 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -207,7 +207,7 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello/world/{idx}": .init( parameters: [ - .parameter(name: "idx", context: .path, schema: .string) + .parameter(name: "idx", context: .path(schema: .string)) ], get: .init( responses: [:] @@ -229,7 +229,7 @@ final class BuiltinValidationTests: XCTestCase { "/hello/world/{idx}": .init( get: .init( parameters: [ - .parameter(name: "idx", context: .path, schema: .string) + .parameter(name: "idx", context: .path(schema: .string)) ], responses: [:] ) @@ -496,8 +496,8 @@ final class BuiltinValidationTests: XCTestCase { "/hello": .init( get: .init( parameters: [ - .parameter(name: "hiya", context: .path, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string) + .parameter(name: "hiya", context: .path(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)) ], responses: [ 200: .response(description: "hi") @@ -524,9 +524,9 @@ final class BuiltinValidationTests: XCTestCase { "/hello": .init( get: .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string), // changes parameter location but not name - .parameter(name: "cool", context: .path, schema: .string) // changes parameter name but not location + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)), // changes parameter location but not name + .parameter(name: "cool", context: .path(schema: .string)) // changes parameter name but not location ], responses: [ 200: .response(description: "hi") @@ -646,8 +646,8 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello": .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .query, schema: .string) + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .query(schema: .string)) ], get: .init( responses: [ @@ -674,9 +674,9 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello": .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string), // changes parameter location but not name - .parameter(name: "cool", context: .path, schema: .string) // changes parameter name but not location + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)), // changes parameter location but not name + .parameter(name: "cool", context: .path(schema: .string)) // changes parameter name but not location ], get: .init( responses: [ @@ -845,7 +845,7 @@ final class BuiltinValidationTests: XCTestCase { "response1": .init(description: "test") ], parameters: [ - "parameter1": .init(name: "test", context: .header, schema: .string) + "parameter1": .init(name: "test", context: .header(schema: .string)) ], examples: [ "example1": .init(value: .b("hello")) diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index 61f028303..61ec37a7a 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -296,8 +296,8 @@ final class ValidationConvenienceTests: XCTestCase { ], components: .init( parameters: [ - "test1": .init(name: "test", context: .header, content: [:]), - "test2": .init(name: "test2", context: .query, content: [:]) + "test1": .init(name: "test", context: .header(content: [:])), + "test2": .init(name: "test2", context: .query(content: [:])) ] ) ) @@ -338,8 +338,8 @@ final class ValidationConvenienceTests: XCTestCase { ], components: .init( parameters: [ - "test1": .init(name: "test", context: .header, content: [:]), - "test2": .init(name: "test2", context: .query, content: [:]) + "test1": .init(name: "test", context: .header(content: [:])), + "test2": .init(name: "test2", context: .query(content: [:])) ] ) ) From cb27c62132be697fb91c95f0a6964d36bc243bdd Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 9 Nov 2025 16:55:19 -0600 Subject: [PATCH 20/73] update migration guide --- .../migration_guides/v5_migration_guide.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 452cebf4e..cabb0b268 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -66,6 +66,89 @@ builtin methods so the following code _does not need to change_: let httpMethod : OpenAPI.HttpMethod = .post ``` +### Parameters +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification). + +For the `OpenAPIKit` module (OAS 3.1.x and 3.2.x versions) read on. + +An additional parameter location of `querystring` has been added. This is a +breaking change to code that exhaustively switches on `OpenAPI.Parameter.Context` +or `OpenAPI.Parameter.Context.Location`. + +To support the new `querystring` location, `schemaOrContent` has been moved into +the `OpenAPI.Parameter.Context` because it only applies to locations other than +`querystring`. You can still access `schemaOrContent` as a property on the +`Parameter`. Code that pattern matches on cases of `OpenAPI.Parameter.Context` +will need to add the new `schemaOrContent` values associated with each case. + +```swift +// BEFORE +switch parameter.context { +case .query(required: _) +} + +// AFTER +switch parameter.context { +case .query(required: _, schemaOrContent: _) +} +``` + +#### Constructors +The following only applies if you construct parameters in-code (use Swift to +build an OpenAPI Document). + +Unfortunately, the change that made `schemaOrContent` not apply to all possible +locations means that the existing convenience constructors and static functions +that created parameters in-code do not make sense anymore. There were fairly +substantial changes to what is available with an aim to continue to offer +simular convenience as before. + +Following are a few changes you made need to make with examples. + +Code that populates the `parameters` array of the `OpenAPI.Operation` type with the +`.parameter(name:,context:,schema:)` function needs to be updated. The `schema` +has moved into the `context` so you change your code in the following way: +```swift +// BEFORE +.parameter( + name: "name", + context: .header, + schema: .string +) + +// AFTER +.parameter( + name: "name", + context: .header(schema: .string) +) +``` + +Code that initializes `OpenAPI.Parameter` via one of its `init` functions will +most likely need to change. Many of the initializers have been removed but you can +replace `.init(name:,context:,schema:)` or similar initializers with +`.header(name:,schema:)` (same goes for `query`, `path`, and `cookie`). So you change +your code in the following way: +```swift +// BEFORE +.init( + name: "name", + context: .header, + schema: .string +) + +// AFTER +.header( + name: "name", + schema: .string +) +``` + +Because the `ParameterContext` has taken on the `schemaOrContent` of the +`Parameter`, convenience constructors like `ParameterContext.header` (and +similar for the other locations) no longer make sense and have been removed. You +must also specify the schema or content, e.g. `ParameterContext.header(schema: .string)`. + ### 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 From ffc413a371fc9c4d537da0d0fe6a11b210822d53 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 05:38:12 -0600 Subject: [PATCH 21/73] add new cookie paremeter style --- .../Parameter/ParameterSchemaContext.swift | 49 ++++++++++++++++++- .../ParameterSchemaContextStyle.swift | 19 +++++++ Sources/OpenAPIKit/_CoreReExport.swift | 4 -- .../ParameterSchemaContextStyle.swift | 4 +- Sources/OpenAPIKit30/_CoreReExport.swift | 4 -- Sources/OpenAPIKitCompat/Compat30To31.swift | 24 +++++++-- .../DocumentConversionTests.swift | 22 ++++++++- .../Parameter/ParameterSchemaTests.swift | 5 ++ 8 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift rename Sources/{OpenAPIKitCore/Shared => OpenAPIKit30/Parameter}/ParameterSchemaContextStyle.swift (70%) diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index c3ef75243..c7b3b02fd 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -12,7 +12,7 @@ extension OpenAPI.Parameter { /// /// See [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object) /// and [OpenAPI Style Values](https://spec.openapis.org/oas/v3.1.1.html#style-values). - public struct SchemaContext: Equatable, Sendable { + public struct SchemaContext: HasConditionalWarnings, Sendable { public var style: Style public var explode: Bool public var allowReserved: Bool //defaults to false @@ -21,6 +21,8 @@ extension OpenAPI.Parameter { public var example: AnyCodable? public var examples: OpenAPI.Example.Map? + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init(_ schema: JSONSchema, style: Style, explode: Bool, @@ -32,6 +34,8 @@ extension OpenAPI.Parameter { self.schema = .init(schema) self.example = example self.examples = nil + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -45,6 +49,8 @@ extension OpenAPI.Parameter { self.examples = nil self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -58,6 +64,8 @@ extension OpenAPI.Parameter { self.schema = .init(schemaReference) self.example = example self.examples = nil + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -71,6 +79,8 @@ extension OpenAPI.Parameter { self.examples = nil self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -84,6 +94,8 @@ extension OpenAPI.Parameter { self.schema = .init(schema) self.examples = examples self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -97,6 +109,8 @@ extension OpenAPI.Parameter { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -110,6 +124,8 @@ extension OpenAPI.Parameter { self.schema = .init(schemaReference) self.examples = examples self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -123,10 +139,39 @@ extension OpenAPI.Parameter { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } } } +extension OpenAPI.Parameter.SchemaContext.Style { + fileprivate var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { + let cookieStyleWarning: (any Condition, OpenAPI.Warning)? + if self != .cookie { + cookieStyleWarning = nil + } else { + cookieStyleWarning = OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The cookie style") + } + + + return [ + cookieStyleWarning + ].compactMap { $0 } + } +} + +extension OpenAPI.Parameter.SchemaContext: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.style == rhs.style + && lhs.allowReserved == rhs.allowReserved + && lhs.explode == rhs.explode + && lhs.schema == rhs.schema + && lhs.examples == rhs.examples + && lhs.example == rhs.example + } +} + extension OpenAPI.Parameter.SchemaContext { public static func schema(_ schema: JSONSchema, style: Style, @@ -278,6 +323,8 @@ extension OpenAPI.Parameter.SchemaContext { examples = examplesMap example = examplesMap.flatMap(OpenAPI.Content.firstExample(from:)) } + + self.conditionalWarnings = style.conditionalWarnings } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift new file mode 100644 index 000000000..96260473e --- /dev/null +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift @@ -0,0 +1,19 @@ +// +// ParameterSchemaContextStyle.swift +// +// +// Created by Mathew Polzin on 12/18/22. +// + +extension OpenAPI.Parameter.SchemaContext { + public enum Style: String, CaseIterable, Codable, Sendable { + case form + case simple + case matrix + case label + case spaceDelimited + case pipeDelimited + case deepObject + case cookie + } +} diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index 997063193..17e2b37ea 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -31,10 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.SchemaContext { - typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle -} - public extension OpenAPI.Response { typealias StatusCode = OpenAPIKitCore.Shared.ResponseStatusCode } diff --git a/Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift b/Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift similarity index 70% rename from Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift rename to Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift index a3166d6dc..41d236e51 100644 --- a/Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift +++ b/Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift @@ -5,8 +5,8 @@ // Created by Mathew Polzin on 12/18/22. // -extension Shared { - public enum ParameterSchemaContextStyle: String, CaseIterable, Codable, Sendable { +extension OpenAPI.Parameter.SchemaContext { + public enum Style: String, CaseIterable, Codable, Sendable { case form case simple case matrix diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index 997063193..17e2b37ea 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -31,10 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.SchemaContext { - typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle -} - public extension OpenAPI.Response { typealias StatusCode = OpenAPIKitCore.Shared.ResponseStatusCode } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 06b210672..a80d74b3c 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -175,7 +175,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { if let newExamples { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schemaReference: .init(ref.to31()), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, examples: newExamples @@ -183,7 +183,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } else { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schemaReference: .init(ref.to31()), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, example: example @@ -193,7 +193,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { if let newExamples { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schema.to31(), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, examples: newExamples @@ -201,7 +201,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } else { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schema.to31(), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, example: example @@ -211,12 +211,26 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } } +extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext.Style: To31 { + fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter.SchemaContext.Style { + switch self { + case .form: .form + case .simple: .simple + case .matrix: .matrix + case .label: .label + case .spaceDelimited: .spaceDelimited + case .pipeDelimited: .pipeDelimited + case .deepObject: .deepObject + } + } +} + extension OpenAPIKit30.OpenAPI.Content.Encoding: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Content.Encoding { OpenAPIKit.OpenAPI.Content.Encoding( contentTypes: [contentType].compactMap { $0 }, headers: headers?.mapValues(eitherRefTo31), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved ) diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 4f73d05d2..11a0c75b0 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1254,11 +1254,29 @@ fileprivate func assertEqualNewToOld(_ newEncoding: OpenAPIKit.OpenAPI.Content.E } else { XCTAssertNil(oldEncoding.headers) } - XCTAssertEqual(newEncoding.style, oldEncoding.style) + try assertEqualNewToOld(newEncoding.style, oldEncoding.style) XCTAssertEqual(newEncoding.explode, oldEncoding.explode) XCTAssertEqual(newEncoding.allowReserved, oldEncoding.allowReserved) } +fileprivate func assertEqualNewToOld(_ newStyle: OpenAPIKit.OpenAPI.Parameter.SchemaContext.Style, _ oldStyle: OpenAPIKit30.OpenAPI.Parameter.SchemaContext.Style) throws { + let equal: Bool + switch (newStyle, oldStyle) { + case (.form, .form): equal = true + case (.simple, .simple): equal = true + case (.matrix, .matrix): equal = true + case (.label, .label): equal = true + case (.spaceDelimited, .spaceDelimited): equal = true + case (.pipeDelimited, .pipeDelimited): equal = true + case (.deepObject, .deepObject): equal = true + default: equal = false + } + + if !equal { + XCTFail("New \(newStyle) is not equivalent to old \(oldStyle)") + } +} + fileprivate func assertEqualNewToOld(_ newHeader: OpenAPIKit.OpenAPI.Header, _ oldHeader: OpenAPIKit30.OpenAPI.Header) throws { XCTAssertEqual(newHeader.description, oldHeader.description) XCTAssertEqual(newHeader.required, oldHeader.required) @@ -1275,7 +1293,7 @@ fileprivate func assertEqualNewToOld(_ newHeader: OpenAPIKit.OpenAPI.Header, _ o } fileprivate func assertEqualNewToOld(_ newSchemaContext: OpenAPIKit.OpenAPI.Parameter.SchemaContext, _ oldSchemaContext: OpenAPIKit30.OpenAPI.Parameter.SchemaContext) throws { - XCTAssertEqual(newSchemaContext.style, oldSchemaContext.style) + try assertEqualNewToOld(newSchemaContext.style, oldSchemaContext.style) XCTAssertEqual(newSchemaContext.explode, oldSchemaContext.explode) XCTAssertEqual(newSchemaContext.allowReserved, oldSchemaContext.allowReserved) switch (newSchemaContext.schema, oldSchemaContext.schema) { diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift index 5300f8f3b..0ac00d6f0 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift @@ -210,6 +210,11 @@ final class ParameterSchemaTests: XCTestCase { let t7 = Schema(.string, style: .deepObject) XCTAssertFalse(t7.explode) } + + public func test_cookie_style() { + let t1 = Schema(.string, style: .cookie) + XCTAssertEqual(t1.conditionalWarnings.count, 1) + } } // MARK: - Codable Tests From ebf4d71afebbf0559a6993baeb38de35a53594f7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 05:49:28 -0600 Subject: [PATCH 22/73] add missing conditional warning for querystring parameter location --- Sources/OpenAPIKit/Parameter/Parameter.swift | 34 ++++++++++++++++++- .../Parameter/ParameterTests.swift | 5 +++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index 1d0332a85..c7e8367cf 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -11,7 +11,7 @@ extension OpenAPI { /// OpenAPI Spec "Parameter Object" /// /// See [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). - public struct Parameter: Equatable, CodableVendorExtendable, Sendable { + public struct Parameter: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var name: String /// OpenAPI Spec "in" property determines the `Context`. @@ -32,6 +32,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + /// Whether or not this parameter is required. See the context /// which determines whether the parameter is required or not. public var required: Bool { context.required } @@ -84,10 +86,38 @@ extension OpenAPI { self.description = description self.deprecated = deprecated self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = context.location.conditionalWarnings } } } +extension OpenAPI.Parameter: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.name == rhs.name + && lhs.context == rhs.context + && lhs.description == rhs.description + && lhs.deprecated == rhs.deprecated + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +extension OpenAPI.Parameter.Context.Location { + fileprivate var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { + let querystringWarning: (any Condition, OpenAPI.Warning)? + if self != .querystring { + querystringWarning = nil + } else { + querystringWarning = OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The querystring parameter location") + } + + + return [ + querystringWarning + ].compactMap { $0 } + } +} + extension OpenAPI.Parameter { /// An array of parameters that are `Either` `Parameters` or references to parameters. /// @@ -595,6 +625,8 @@ extension OpenAPI.Parameter: Decodable { deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = context.location.conditionalWarnings } } diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift index bb8bd8035..554779cde 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift @@ -95,6 +95,11 @@ final class ParameterTests: XCTestCase { XCTAssertEqual(t1[0].parameterValue, OpenAPI.Parameter.cookie(name: "hello", schema: .string)) XCTAssertEqual(t1[4].reference, .component( named: "hello")) } + + func test_querystringLocation() { + let t1 = OpenAPI.Parameter.querystring(name: "string", content: [:]) + XCTAssertEqual(t1.conditionalWarnings.count, 1) + } } // MARK: - Codable Tests From ea62ddbbab04943026d3ddb7ad529fd2c167d7a3 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 09:25:52 -0600 Subject: [PATCH 23/73] add new builtin validation for parameter schema styles --- Sources/OpenAPIKit/Parameter/Parameter.swift | 8 + .../Validator/Validation+Builtins.swift | 52 ++++ Sources/OpenAPIKit/Validator/Validator.swift | 15 +- .../Validator/BuiltinValidationTests.swift | 256 ++++++++++++++++++ 4 files changed, 324 insertions(+), 7 deletions(-) diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index c7e8367cf..40952463e 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -73,6 +73,14 @@ extension OpenAPI { } } + /// The parameter's schema `style`, if defined. Note that this is + /// guaranteed to be nil if the parameter has `content` defined. Use + /// the `schemaOrContent` property if you want to switch over the two + /// possibilities. + public var schemaStyle : SchemaContext.Style? { + schemaOrContent.schemaContextValue?.style + } + /// Create a parameter. public init( name: String, diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 5df0cb34b..def7c7f79 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -505,6 +505,58 @@ extension Validation { } ) } + + /// Validate the OpenAPI Document's `Parameter`s all have styles that are + /// compatible with their locations per the table found at + /// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.2.0.md#style-values + /// + /// - Important: This is included in validation by default. + public static var parameterStyleAndLocationAreCompatible: Validation { + .init( + check: all( + Validation( + description: "the matrix style can only be used for the path location", + check: \.context.location == .path, + when: \.schemaStyle == .matrix + ), + Validation( + description: "the label style can only be used for the path location", + check: \.context.location == .path, + when: \.schemaStyle == .label + ), + Validation( + description: "the simple style can only be used for the path and header locations", + check: \.context.location == .path || \.context.location == .header, + when: \.schemaStyle == .simple + ), + Validation( + description: "the form style can only be used for the query and cookie locations", + check: \.context.location == .query || \.context.location == .cookie, + when: \.schemaStyle == .form + ), + Validation( + description: "the spaceDelimited style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .spaceDelimited + ), + Validation( + description: "the pipeDelimited style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .pipeDelimited + ), + Validation( + description: "the deepObject style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .deepObject + ), + Validation( + description: "the cookie style can only be used for the cookie location", + check: \.context.location == .cookie, + when: \.schemaStyle == .cookie + ) + ) + ) + } } /// Used by both the Path Item parameter check and the diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 49e1b8fdc..66fcd8611 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -170,12 +170,12 @@ public final class Validator { /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. /// - Operation Ids are unique across the whole Document. - /// - All OpenAPI.References that refer to components in this - /// document can be found in the components dictionary. - /// - `Enum` must not be empty in the document's - /// Server Variable. - /// - `Default` must exist in the enum values in the document's - /// Server Variable. + /// - All OpenAPI.References that refer to components in this document can + /// be found in the components dictionary. + /// - `Enum` must not be empty in the document's Server Variable. + /// - `Default` must exist in the enum values in the document's Server + /// Variable. + /// - `Parameter` styles and locations are compatible with each other. /// public convenience init() { self.init(validations: [ @@ -193,7 +193,8 @@ public final class Validator { .init(.callbacksReferencesAreValid), .init(.pathItemReferencesAreValid), .init(.serverVariableEnumIsValid), - .init(.serverVariableDefaultExistsInEnum) + .init(.serverVariableDefaultExistsInEnum), + .init(.parameterStyleAndLocationAreCompatible) ]) } diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 25a761ae9..57bf0d092 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -951,4 +951,260 @@ final class BuiltinValidationTests: XCTestCase { XCTAssertTrue((errorCollection?.values.first?.codingPath.map { $0.stringValue }.joined(separator: ".") ?? "").contains("testLink")) } } + + func test_badMatrixStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .matrix)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the matrix style can only be used for the path location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badLabelStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .label)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the label style can only be used for the path location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badSimpleStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .simple)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the simple style can only be used for the path and header locations") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badFormStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .form)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the form style can only be used for the query and cookie locations") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badSpaceDelimitedStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .spaceDelimited)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the spaceDelimited style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badPipeDelimitedStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .pipeDelimited)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the pipeDelimited style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badDeepObjectStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .deepObject)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the deepObject style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badCookieStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .cookie)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the cookie style can only be used for the cookie location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } } From 72cc8474d7adaed4a69968fa2f22a7010f8a7553 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 09:35:19 -0600 Subject: [PATCH 24/73] update migration guide --- documentation/migration_guides/v5_migration_guide.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index cabb0b268..54eac43e9 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -15,6 +15,9 @@ v5.10 and greater). Only relevant when compiling OpenAPIKit on iOS: Now v12+ is required. ### OpenAPI Specification Versions +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + The OpenAPIKit module's `OpenAPI.Document.Version` enum gained `v3_1_2`, `v3_2_0` and `v3_2_x(x: Int)`. @@ -68,7 +71,7 @@ let httpMethod : OpenAPI.HttpMethod = .post ### Parameters There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x -specification). +specification) in this section. For the `OpenAPIKit` module (OAS 3.1.x and 3.2.x versions) read on. @@ -149,6 +152,13 @@ Because the `ParameterContext` has taken on the `schemaOrContent` of the similar for the other locations) no longer make sense and have been removed. You must also specify the schema or content, e.g. `ParameterContext.header(schema: .string)`. +### Parameter Styles +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +A new `cookie` style has been added. Code that exhaustively switches on the +`OpenAPI.Parameter.SchemaContext.Style` enum will need to be updated. + ### 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 From a31904f85cd23c0ac44adf8569a1a9f2f5d12a9c Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 10:21:58 -0600 Subject: [PATCH 25/73] Add response object summary field. make response object description optional --- .../OpenAPIConditionalWarnings.swift | 6 ++ Sources/OpenAPIKit/Response/Response.swift | 79 +++++++++++++++++-- .../ResponseErrorTests.swift | 30 ------- .../Response/ResponseTests.swift | 44 +++++++++++ 4 files changed, 122 insertions(+), 37 deletions(-) diff --git a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift index ed4092da2..6e47cab74 100644 --- a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift +++ b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift @@ -51,5 +51,11 @@ internal extension OpenAPI.Document { return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) } + + static func version(lessThan version: OpenAPI.Document.Version, doesNotAllowOptional subject: String) -> (any Condition, OpenAPI.Warning) { + let warning = OpenAPI.Warning.message("\(subject) cannot be nil for OpenAPI document versions lower than \(version.rawValue)") + + return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) + } } } diff --git a/Sources/OpenAPIKit/Response/Response.swift b/Sources/OpenAPIKit/Response/Response.swift index af5d25365..b03c3ff03 100644 --- a/Sources/OpenAPIKit/Response/Response.swift +++ b/Sources/OpenAPIKit/Response/Response.swift @@ -11,8 +11,10 @@ extension OpenAPI { /// OpenAPI Spec "Response Object" /// /// See [OpenAPI Response Object](https://spec.openapis.org/oas/v3.1.1.html#response-object). - public struct Response: Equatable, CodableVendorExtendable, Sendable { - public var description: String + public struct Response: HasConditionalWarnings, CodableVendorExtendable, Sendable { + public var summary: String? + public var description: String? + public var headers: Header.Map? /// An empty Content map will be omitted from encoding. public var content: Content.Map @@ -26,22 +28,62 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( - description: String, + summary: String? = nil, + description: String? = nil, headers: Header.Map? = nil, content: Content.Map = [:], links: Link.Map = [:], vendorExtensions: [String: AnyCodable] = [:] ) { + self.summary = summary self.description = description self.headers = headers self.content = content self.links = links self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +extension OpenAPI.Response: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.headers == rhs.headers + && lhs.content == rhs.content + && lhs.links == rhs.links + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Response \(fieldName) field" + ) + } +} + +fileprivate func notOptionalVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + guard value == nil else { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotAllowOptional: "The Response \(fieldName) field" + ) +} + extension OpenAPI.Response { public typealias Map = OrderedDictionary, OpenAPI.Response>> } @@ -72,7 +114,8 @@ extension OrderedDictionary where Key == OpenAPI.Response.StatusCode { extension Either where A == OpenAPI.Reference, B == OpenAPI.Response { public static func response( - description: String, + summary: String? = nil, + description: String? = nil, headers: OpenAPI.Header.Map? = nil, content: OpenAPI.Content.Map = [:], links: OpenAPI.Link.Map = [:] @@ -89,19 +132,27 @@ extension Either where A == OpenAPI.Reference, B == OpenAPI.Re } // MARK: - Describable -extension OpenAPI.Response: OpenAPIDescribable { +extension OpenAPI.Response: OpenAPISummarizable { public func overriddenNonNil(description: String?) -> OpenAPI.Response { guard let description = description else { return self } var response = self response.description = description return response } + + public func overriddenNonNil(summary: String?) -> OpenAPI.Response { + guard let summary = summary else { return self } + var response = self + response.summary = summary + return response + } } // MARK: - Codable extension OpenAPI.Response { internal enum CodingKeys: ExtendableCodingKey { + case summary case description case headers case content @@ -110,6 +161,7 @@ extension OpenAPI.Response { static var allBuiltinKeys: [CodingKeys] { return [ + .summary, .description, .headers, .content, @@ -123,6 +175,8 @@ extension OpenAPI.Response { init?(stringValue: String) { switch stringValue { + case "summary": + self = .summary case "description": self = .description case "headers": @@ -138,6 +192,8 @@ extension OpenAPI.Response { var stringValue: String { switch self { + case .summary: + return "summary" case .description: return "description" case .headers: @@ -157,7 +213,8 @@ extension OpenAPI.Response: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(description, forKey: .description) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(headers, forKey: .headers) if !content.isEmpty { @@ -179,13 +236,21 @@ extension OpenAPI.Response: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) do { - description = try container.decode(String.self, forKey: .description) + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers) content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:] links = try container.decodeIfPresent(OpenAPI.Link.Map.self, forKey: .links) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } + } catch let error as GenericError { throw OpenAPI.Error.Decoding.Response(error) diff --git a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift index fba1f7fd3..ff178666a 100644 --- a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift @@ -52,36 +52,6 @@ final class ResponseErrorTests: XCTestCase { } } - func test_missingDescriptionResponseObject() { - let documentYML = - """ - openapi: "3.1.0" - info: - title: test - version: 1.0 - paths: - /hello/world: - get: - responses: - '200': - not-a-thing: hi - """ - - XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in - - let openAPIError = OpenAPI.Error(from: error) - - XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Response in .responses.200 for the **GET** endpoint under `/hello/world`. \n\nResponse could not be decoded because:\nExpected to find `description` key but it is missing..") - XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ - "paths", - "/hello/world", - "get", - "responses", - "200" - ]) - } - } - func test_badResponseExtension() { let documentYML = """ diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index 1f4367ff7..0d85c85ae 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -25,6 +25,28 @@ final class ResponseTests: XCTestCase { XCTAssertEqual(r2.description, "") XCTAssertEqual(r2.headers?["hello"]?.headerValue, header) XCTAssertEqual(r2.content, [.json: content]) + XCTAssertEqual(r2.conditionalWarnings.count, 0) + + // two OAS 3.2.0 warnings: summary is used and description is not + let r3 = OpenAPI.Response(summary: "", + content: [:]) + XCTAssertEqual(r3.summary, "") + XCTAssertNil(r3.description) + XCTAssertEqual(r3.conditionalWarnings.count, 2) + + // one OAS 3.2.0 warnings: summary is used + let r4 = OpenAPI.Response(summary: "", + description: "", + content: [:]) + XCTAssertEqual(r4.summary, "") + XCTAssertEqual(r4.description, "") + XCTAssertEqual(r4.conditionalWarnings.count, 1) + + // one OAS 3.2.0 warnings: description is not used + let r5 = OpenAPI.Response(content: [:]) + XCTAssertNil(r5.summary) + XCTAssertNil(r5.description) + XCTAssertEqual(r5.conditionalWarnings.count, 1) } func test_responseMap() { @@ -122,6 +144,18 @@ extension ResponseTests { } """ ) + + let response3 = OpenAPI.Response(summary: "", content: [:]) + let encodedResponse3 = try! orderUnstableTestStringFromEncoding(of: response3) + + assertJSONEquivalent( + encodedResponse3, + """ + { + "summary" : "" + } + """ + ) } func test_emptyDescriptionEmptyContent_decode() { @@ -157,6 +191,16 @@ extension ResponseTests { let response3 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData3) XCTAssertEqual(response3, OpenAPI.Response(description: "", headers: [:], content: [:])) + + let responseData4 = + """ + { + "summary" : "" + } + """.data(using: .utf8)! + let response4 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData4) + + XCTAssertEqual(response4, OpenAPI.Response(summary: "", content: [:])) } func test_populatedDescriptionPopulatedContent_encode() { From a9a85de699527f5656326081e0fb0ff7daca6c0a Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 11 Nov 2025 08:54:15 -0600 Subject: [PATCH 26/73] update migration guide --- documentation/migration_guides/v5_migration_guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 54eac43e9..0a94ebf12 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -159,6 +159,13 @@ specification) in this section. A new `cookie` style has been added. Code that exhaustively switches on the `OpenAPI.Parameter.SchemaContext.Style` enum will need to be updated. +### Response Objects +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +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`. + ### 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 From 0cd62a51a2bb954cf99c6f8779c63d6cf21848df Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 13 Nov 2025 10:17:14 -0600 Subject: [PATCH 27/73] 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 28/73] 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 29/73] 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 30/73] 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 From 8710e388ceaf515d5402c1fac25cac0c9c6bc0c7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 18 Nov 2025 19:34:10 -0600 Subject: [PATCH 31/73] align existing schema property with spec --- .../Components+JSONReference.swift | 57 +++++++++++++ Sources/OpenAPIKit/Content/Content.swift | 25 ++++-- .../OpenAPIKit/Schema Object/JSONSchema.swift | 5 ++ .../Validator/Validation+Builtins.swift | 26 +++++- Sources/OpenAPIKit/Validator/Validator.swift | 1 + Sources/OpenAPIKitCompat/Compat30To31.swift | 11 ++- .../DocumentConversionTests.swift | 8 +- .../SchemaErrorTests.swift | 2 +- Tests/OpenAPIKitTests/ComponentsTests.swift | 80 +++++++++++++++---- .../Content/ContentTests.swift | 13 +-- .../Request/RequestTests.swift | 6 +- .../Response/ResponseTests.swift | 2 +- .../Validator/ValidatorTests.swift | 16 ++-- documentation/validation.md | 8 +- 14 files changed, 204 insertions(+), 56 deletions(-) diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index fc709c9b2..5a10f642d 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -114,6 +114,29 @@ extension OpenAPI.Components { return self[localReference] } + /// Retrieve schema from the `Components`. If the JSONSchema is not a + /// reference, it will be returned as-is. If it is a reference, it will be + /// looked up in the components if it refers to a components entry. If the + /// reference does not refer to a components entry, the function will return + /// `nil`. + /// + /// 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(_ schema: JSONSchema) -> JSONSchema? { + guard case .reference(let reference, _) = schema.value else { + return schema + } + guard case .internal(let localReference) = reference else { + return nil + } + + return self[localReference] + } + /// Retrieve item referenced from the `Components`. /// /// This function will follow subsequent refernences found within the @@ -328,6 +351,13 @@ extension OpenAPI.Components { guard let value else { throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) } + + // special case for JSONSchemas that are references + if let schema = value as? JSONSchema, + case let .reference(newReference, _) = schema.value { + return try _lookup(newReference, following: visitedReferences.union([reference])) as! ReferenceType + } + return value case .b(let referencePath): @@ -374,6 +404,33 @@ extension OpenAPI.Components { } } + /// Lookup schema in the `Components`. If the JSONSchema is not a + /// reference, it will be returned as-is. If it is a reference, it will be + /// looked up in the components if it refers to a components entry. + /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. + /// + /// 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(_ schema: JSONSchema) throws -> JSONSchema { + guard case .reference(let reference, _) = schema.value else { + return schema + } + return try lookup(reference) + } + /// Create an `OpenAPI.Reference`. /// /// - Throws: If the given name does not refer to an existing component of the given type. diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 0d45bd53b..754e9ad6f 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -12,7 +12,7 @@ extension OpenAPI { /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). public struct Content: Equatable, CodableVendorExtendable, Sendable { - public var schema: Either, JSONSchema>? + public var schema: JSONSchema? public var example: AnyCodable? public var examples: Example.Map? public var encoding: OrderedDictionary? @@ -27,7 +27,7 @@ extension OpenAPI { /// Create `Content` with a schema, a reference to a schema, or no /// schema at all and optionally provide a single example. public init( - schema: Either, JSONSchema>?, + schema: JSONSchema?, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] @@ -47,7 +47,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schemaReference) + self.schema = .reference(schemaReference.jsonReference) self.example = example self.examples = nil self.encoding = encoding @@ -62,7 +62,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + self.schema = schema self.example = example self.examples = nil self.encoding = encoding @@ -77,7 +77,16 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = schema + switch schema { + case .none: + self.schema = nil + + case .some(.a(let reference)): + self.schema = .reference(reference.jsonReference) + + case .some(.b(let schemaValue)): + self.schema = schemaValue + } self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -92,7 +101,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schemaReference) + self.schema = .reference(schemaReference.jsonReference) self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -107,7 +116,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + self.schema = schema self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -179,7 +188,7 @@ extension OpenAPI.Content: Decodable { ) } - schema = try container.decodeIfPresent(Either, JSONSchema>.self, forKey: .schema) + schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index fa4a2b28b..0a4202137 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -358,6 +358,11 @@ extension JSONSchema { guard case .reference = value else { return false } return true } + + public var reference: JSONReference? { + guard case let .reference(reference, _) = value else { return nil } + return reference + } } // MARK: - Context Accessors diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index def7c7f79..672d38a67 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -254,14 +254,14 @@ extension Validation { ) } - /// Validate that all non-external JSONSchema references are found in the document's + /// Validate that all non-external OpenAPI JSONSchema references are found in the document's /// components dictionary. /// /// - Important: This is included in validation by default. /// public static var schemaReferencesAreValid: Validation> { .init( - description: "JSONSchema reference can be found in components/schemas", + description: "OpenAPI JSONSchema reference can be found in components/schemas", check: { context in guard case let .internal(internalReference) = context.subject.jsonReference, case .component = internalReference else { @@ -276,6 +276,28 @@ extension Validation { ) } + /// Validate that all non-external JSONSchema references are found in the document's + /// components dictionary. + /// + /// - Important: This is included in validation by default. + /// + public static var jsonSchemaReferencesAreValid: Validation { + .init( + description: "JSONSchema reference can be found in components/schemas", + check: { context in + guard case let .internal(internalReference) = context.subject.reference, + case .component = internalReference else { + // don't make assertions about external references + // TODO: could make a stronger assertion including + // internal references outside of components given + // some way to resolve those references. + return true + } + return context.document.components.contains(internalReference) + } + ) + } + /// Validate that all non-external Response references are found in the document's /// components dictionary. /// diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 66fcd8611..bfaa7174b 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -184,6 +184,7 @@ public final class Validator { .init(.operationParametersAreUnique), .init(.operationIdsAreUnique), .init(.schemaReferencesAreValid), + .init(.jsonSchemaReferencesAreValid), .init(.responseReferencesAreValid), .init(.parameterReferencesAreValid), .init(.exampleReferencesAreValid), diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 2ab98b06c..df5b3193b 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -247,8 +247,17 @@ extension OpenAPIKit30.OpenAPI.Content: To31 { vendorExtensions: vendorExtensions ) } else { + let eitherRef = schema.map(eitherRefTo31) + let schema: OpenAPIKit.JSONSchema? = switch eitherRef { + case .none: + nil + case .a(let reference): + .reference(reference.jsonReference) + case .b(let value): + value + } return OpenAPIKit.OpenAPI.Content( - schema: schema.map(eitherRefTo31), + schema: schema, example: example, encoding: encoding?.mapValues { $0.to31() }, vendorExtensions: vendorExtensions diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 9568e15e1..029f135c4 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1086,11 +1086,11 @@ fileprivate func assertEqualNewToOld(_ newRequest: OpenAPIKit.OpenAPI.Request, _ fileprivate func assertEqualNewToOld(_ newContentMap: OpenAPIKit.OpenAPI.Content.Map, _ oldContentMap: OpenAPIKit30.OpenAPI.Content.Map) throws { for ((newCt, newContent), (oldCt, oldContent)) in zip(newContentMap, oldContentMap) { XCTAssertEqual(newCt, oldCt) - switch (newContent.schema, oldContent.schema) { - case (.a(let ref1), .a(let ref2)): + switch (newContent.schema?.value, oldContent.schema) { + case (.reference(let ref1, _), .a(let ref2)): XCTAssertEqual(ref1.absoluteString, ref2.absoluteString) - case (.b(let schema1), .b(let schema2)): - try assertEqualNewToOld(schema1, schema2) + case (let .some(schema1), .b(let schema2)): + try assertEqualNewToOld(.init(schema:schema1), schema2) default: XCTFail("Found one reference and one schema. \(String(describing: newContent.schema)) / \(String(describing: oldContent.schema))") } diff --git a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift index 4aaca5ff4..a8f913f35 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift @@ -35,7 +35,7 @@ final class SchemaErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a JSONSchema in .content['application/json'].schema for the status code '200' response of the **GET** endpoint under `/hello/world`. \n\nJSONSchema could not be decoded because:\nProblem encountered when parsing `maximum`: Expected an Integer literal but found a floating point value (1.234)..") + XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `maximum` in .content['application/json'].schema for the status code '200' response of the **GET** endpoint under `/hello/world`: Expected an Integer literal but found a floating point value (1.234).") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", "/hello/world", diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 3a7ad1d35..0d15cca4c 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -28,7 +28,8 @@ final class ComponentsTests: XCTestCase { func test_directConstructor() { let c1 = OpenAPI.Components( schemas: [ - "one": .string + "one": .string, + "ref": .reference(.component(named: "one")) ], responses: [ "two": .response(.init(description: "hello", content: [:])) @@ -74,7 +75,8 @@ final class ComponentsTests: XCTestCase { let c2 = OpenAPI.Components.direct( schemas: [ - "one": .string + "one": .string, + "ref": .reference(.component(named: "one")) ], responses: [ "two": .init(description: "hello", content: [:]) @@ -125,7 +127,8 @@ final class ComponentsTests: XCTestCase { let components = OpenAPI.Components( schemas: [ "hello": .string, - "world": .integer(required: false) + "world": .integer(required: false), + "ref": .reference(.component(named: "hello")) ], parameters: [ "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), @@ -135,30 +138,33 @@ final class ComponentsTests: XCTestCase { let ref1 = JSONReference.component(named: "world") let ref2 = JSONReference.component(named: "missing") - let ref3 = JSONReference.component(named: "param") + let ref3 = JSONReference.component(named: "ref") + let ref4 = JSONReference.component(named: "param") XCTAssertEqual(components[ref1], .integer(required: false)) XCTAssertEqual(try? components.lookup(ref1), components[ref1]) XCTAssertNil(components[ref2]) - XCTAssertNil(components[ref3]) + XCTAssertEqual(components[ref3], .string) + XCTAssertEqual(try? components.lookup(ref3), components[ref3]) + XCTAssertNil(components[ref4]) - let ref4 = JSONReference.InternalReference.component(name: "world") - let ref5 = JSONReference.InternalReference.component(name: "missing") - let ref6 = JSONReference.InternalReference.component(name: "param") + let ref5 = JSONReference.InternalReference.component(name: "world") + let ref6 = JSONReference.InternalReference.component(name: "missing") + let ref7 = JSONReference.InternalReference.component(name: "param") - XCTAssertEqual(components[ref4], .integer(required: false)) - XCTAssertNil(components[ref5]) + XCTAssertEqual(components[ref5], .integer(required: false)) XCTAssertNil(components[ref6]) + XCTAssertNil(components[ref7]) - let ref7 = JSONReference.component(named: "my_param") + let ref8 = JSONReference.component(named: "my_param") - XCTAssertEqual(components[ref7], .cookie(name: "my_param", schema: .string)) + XCTAssertEqual(components[ref8], .cookie(name: "my_param", schema: .string)) - let ref8 = JSONReference.external(URL(string: "hello.json")!) + let ref9 = JSONReference.external(URL(string: "hello.json")!) - XCTAssertNil(components[ref8]) + XCTAssertNil(components[ref9]) - XCTAssertThrowsError(try components.contains(ref8)) + XCTAssertThrowsError(try components.contains(ref9)) } func test_lookupOnce() throws { @@ -411,6 +417,50 @@ extension ComponentsTests { XCTAssertEqual(decoded, OpenAPI.Components()) } + func test_schemaReference_encode() throws { + let t1 = OpenAPI.Components( + schemas: [ + "ref": .reference(.external(URL(string: "./hi.yml")!)) + ] + ) + + let encoded = try orderUnstableTestStringFromEncoding(of: t1) + + assertJSONEquivalent( + encoded, + """ + { + "schemas" : { + "ref" : { + "$ref" : ".\\/hi.yml" + } + } + } + """ + ) + } + + func test_schemaReference_decode() throws { + let t1 = + """ + { + "schemas" : { + "ref" : { + "$ref" : "./hi.yml" + } + } + } + """.data(using: .utf8)! + + let decoded = try orderUnstableDecode(OpenAPI.Components.self, from: t1) + + XCTAssertEqual(decoded, OpenAPI.Components( + schemas: [ + "ref": .reference(.external(URL(string: "./hi.yml")!)) + ] + )) + } + func test_maximal_encode() throws { let t1 = OpenAPI.Components.direct( schemas: [ diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index e4930f4e8..c38519969 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -11,17 +11,14 @@ import OpenAPIKit final class ContentTests: XCTestCase { func test_init() { - let t1 = OpenAPI.Content(schema: .init(.external(URL(string:"hello.json#/world")!))) + let t1 = OpenAPI.Content(schema: .reference(.external(URL(string:"hello.json#/world")!))) XCTAssertNotNil(t1.schema?.reference) - XCTAssertNil(t1.schema?.schemaValue) let t2 = OpenAPI.Content(schema: .init(.string)) - XCTAssertNotNil(t2.schema?.schemaValue) XCTAssertNil(t2.schema?.reference) let t3 = OpenAPI.Content(schemaReference: .external(URL(string: "hello.json#/world")!)) XCTAssertNotNil(t3.schema?.reference) - XCTAssertNil(t3.schema?.schemaValue) let withExample = OpenAPI.Content( schema: .init(.string), @@ -52,13 +49,11 @@ final class ContentTests: XCTestCase { examples: nil ) XCTAssertNotNil(t4.schema?.reference) - XCTAssertNil(t4.schema?.schemaValue) let t5 = OpenAPI.Content( schema: .string, examples: nil ) - XCTAssertNotNil(t5.schema?.schemaValue) XCTAssertNil(t5.schema?.reference) let _ = OpenAPI.Content( @@ -98,7 +93,7 @@ final class ContentTests: XCTestCase { .tar: .init(schema: .init(.boolean)), .tif: .init(schema: .init(.string(contentEncoding: .binary))), .txt: .init(schema: .init(.number)), - .xml: .init(schema: .init(.external(URL(string: "hello.json#/world")!))), + .xml: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))), .yaml: .init(schema: .init(.string)), .zip: .init(schema: .init(.string)), @@ -118,7 +113,7 @@ final class ContentTests: XCTestCase { // MARK: - Codable extension ContentTests { func test_referenceContent_encode() { - let content = OpenAPI.Content(schema: .init(.external(URL(string: "hello.json#/world")!))) + let content = OpenAPI.Content(schema: .reference(.external(URL(string: "hello.json#/world")!))) let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) assertJSONEquivalent( @@ -144,7 +139,7 @@ extension ContentTests { """.data(using: .utf8)! let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) - XCTAssertEqual(content, OpenAPI.Content(schema: .init(.external(URL(string: "hello.json#/world")!)))) + XCTAssertEqual(content, OpenAPI.Content(schema: .reference(.external(URL(string: "hello.json#/world")!)))) } func test_schemaContent_encode() { diff --git a/Tests/OpenAPIKitTests/Request/RequestTests.swift b/Tests/OpenAPIKitTests/Request/RequestTests.swift index a30190e6f..efa58f603 100644 --- a/Tests/OpenAPIKitTests/Request/RequestTests.swift +++ b/Tests/OpenAPIKitTests/Request/RequestTests.swift @@ -37,7 +37,7 @@ final class RequestTests: XCTestCase { ]) let _ = OpenAPI.Request(content: [ - .json: .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ]) } } @@ -61,7 +61,7 @@ extension RequestTests { func test_onlyReferenceContent_encode() { let request = OpenAPI.Request(content: [ - .json: .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ]) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) @@ -86,7 +86,7 @@ extension RequestTests { let request = try! orderUnstableDecode(OpenAPI.Request.self, from: requestData) XCTAssertEqual(request, OpenAPI.Request(content: [ - .json : .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json : .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ])) } diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index 0d85c85ae..fcc27d71f 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -17,7 +17,7 @@ final class ResponseTests: XCTestCase { XCTAssertNil(r1.headers) XCTAssertEqual(r1.content, [:]) - let content = OpenAPI.Content(schema: .init(OpenAPI.Reference.external(URL(string: "hello.yml")!))) + let content = OpenAPI.Content(schema: .reference(.external(URL(string: "hello.yml")!))) let header = OpenAPI.Header(schemaOrContent: .init(.header(.string))) let r2 = OpenAPI.Response(description: "", headers: ["hello": .init(header)], diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index 6c17f8b29..54bb6f7b5 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1157,20 +1157,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( @@ -1287,20 +1287,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( diff --git a/documentation/validation.md b/documentation/validation.md index 8744ce1b2..904e0798f 100644 --- a/documentation/validation.md +++ b/documentation/validation.md @@ -487,17 +487,17 @@ let responseResourceContainsId = Validation( // clause to skip over any requests that do not have such schemas // without error. let requestBodyContainsName = Validation( - check: unwrap(\.content[.json]?.schema?.schemaValue, into: resourceContainsName), + check: unwrap(\.content[.json]?.schema, into: resourceContainsName), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) // Similarly, we check JSON response schemas. This time we check // for both a 'name' and an 'id'. let responseBodyContainsNameAndId = Validation( - check: unwrap(\.content[.json]?.schema?.schemaValue, into: resourceContainsName, responseResourceContainsId), + check: unwrap(\.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) // We are specifically looking only at 201 ("created") status code From eb8de23787a7086f93ef3a081d8483552d49c381 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 19 Nov 2025 10:31:14 -0600 Subject: [PATCH 32/73] fix component name tracking for jsonschema reference dereferencing --- Sources/OpenAPIKit/Content/Content.swift | 2 +- .../Schema Object/DereferencedJSONSchema.swift | 4 ++-- .../DereferencedSchemaObjectTests.swift | 14 +++++++------- .../SchemaFragmentCombiningTests.swift | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 754e9ad6f..05576fef4 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -47,7 +47,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .reference(schemaReference.jsonReference) + self.schema = .reference(schemaReference.jsonReference, description: schemaReference.description) self.example = example self.examples = nil self.encoding = encoding diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index d1a86b33d..43e4ef873 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -485,10 +485,10 @@ extension JSONSchema: LocallyDereferenceable { // TODO: consider which other core context properties to override here as with description ^ var extensions = dereferenced.vendorExtensions - if let name { + if let name = name ?? reference.name { extensions[OpenAPI.Components.componentNameExtension] = .init(name) } - dereferenced = dereferenced.with(vendorExtensions: vendorExtensions) + dereferenced = dereferenced.with(vendorExtensions: extensions) return dereferenced case .boolean(let context): diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index 21502fbeb..ca1ea9e51 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -278,7 +278,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { schemas: ["test": .string] ) let t1 = try JSONSchema.reference(.component(named: "test")).dereferenced(in: components) - XCTAssertEqual(t1, .string(.init(), .init())) + XCTAssertEqual(t1, .string(.init(vendorExtensions: ["x-component-name": "test"]), .init())) } func test_throwingReferenceWithOverriddenDescription() throws { @@ -286,7 +286,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { schemas: ["test": .string] ) let t1 = try JSONSchema.reference(.component(named: "test"), description: "hello").dereferenced(in: components) - XCTAssertEqual(t1, .string(.init(description: "hello"), .init())) + XCTAssertEqual(t1, .string(.init(description: "hello", vendorExtensions: ["x-component-name": "test"]), .init())) } func test_optionalObjectWithoutReferences() { @@ -360,7 +360,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.object(properties: ["test": .reference(.component(named: "test"))]).dereferenced(in: components) XCTAssertEqual( t1, - .object(.init(), DereferencedJSONSchema.ObjectContext(.init(properties: ["test": .boolean]))!) + .object(.init(), DereferencedJSONSchema.ObjectContext(.init(properties: ["test": .boolean(.init(vendorExtensions: ["x-component-name": "test"]))]))!) ) XCTAssertThrowsError(try JSONSchema.object(properties: ["missing": .reference(.component(named: "missing"))]).dereferenced(in: components)) } @@ -394,7 +394,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.array(items: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .array(.init(), DereferencedJSONSchema.ArrayContext(.init(items: .boolean))!) + .array(.init(), DereferencedJSONSchema.ArrayContext(.init(items: .boolean(.init(vendorExtensions: ["x-component-name": "test"]))))!) ) XCTAssertThrowsError(try JSONSchema.array(items: .reference(.component(named: "missing"))).dereferenced(in: components)) } @@ -427,7 +427,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.one(of: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .one(of: [.boolean(.init())], core: .init()) + .one(of: [.boolean(.init(vendorExtensions: ["x-component-name": "test"]))], core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.one(of: .reference(.component(named: "missing"))).dereferenced(in: components)) @@ -461,7 +461,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.any(of: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .any(of: [.boolean(.init())], core: .init()) + .any(of: [.boolean(.init(vendorExtensions: ["x-component-name": "test"]))], core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.any(of: .reference(.component(named: "missing"))).dereferenced(in: components)) @@ -489,7 +489,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.not(.reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .not(.boolean(.init()), core: .init()) + .not(.boolean(.init(vendorExtensions: ["x-component-name": "test"])), core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.not(.reference(.component(named: "missing"))).dereferenced(in: components)) diff --git a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift index c6cc5cf6a..fd529f6d5 100644 --- a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift @@ -575,7 +575,7 @@ final class SchemaFragmentCombiningTests: XCTestCase { let schema1 = try t1.combined(resolvingAgainst: components) XCTAssertEqual( schema1, - JSONSchema.string.dereferenced() + JSONSchema.string(.init(), .init()).dereferenced() ) let t2 = [ @@ -585,7 +585,7 @@ final class SchemaFragmentCombiningTests: XCTestCase { let schema2 = try t2.combined(resolvingAgainst: components) XCTAssertEqual( schema2, - JSONSchema.object(description: "test", properties: ["test": .string]).dereferenced() + JSONSchema.object(description: "test", properties: ["test": .string(.init(vendorExtensions: ["x-component-name": "test"]), .init())]).dereferenced() ) } From 1a2c840f8469031547b50bd8789c821a85f2565b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 19 Nov 2025 21:21:52 -0600 Subject: [PATCH 33/73] addition of positional schema, encoding for media type object --- Sources/OpenAPIKit/Content/Content.swift | 204 +++++++++++++++++- .../Content/ContentPositionalEncoding.swift | 41 ++++ .../Content/DereferencedContent.swift | 52 ++++- ...ereferencedContentPositionalEncoding.swift | 38 ++++ .../Either/Either+Convenience.swift | 11 + .../DocumentConversionTests.swift | 1 + .../Content/ContentTests.swift | 200 +++++++++++++++++ .../Content/DereferencedContentTests.swift | 8 +- 8 files changed, 530 insertions(+), 25 deletions(-) create mode 100644 Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift create mode 100644 Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 05576fef4..f923b9510 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -12,10 +12,31 @@ extension OpenAPI { /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). public struct Content: Equatable, CodableVendorExtendable, Sendable { + /// A schema describing the complete content of the request, response, + /// parameter, or header. public var schema: JSONSchema? + + /// A schema describing each item within a sequential media type. + public var itemSchema: JSONSchema? + public var example: AnyCodable? public var examples: Example.Map? - public var encoding: OrderedDictionary? + + /// Provide either a map of encodings or some combination of prefix- + /// and item- positional encodings. + /// + /// If the OpenAPI Document specifies the 'encoding' key (a map) + /// then this property will be set to its first case. If the OpenAPI + /// Document specifies either or both of the 'prefixEncoding' and + /// 'itemEncoding' keys, this property will be set to its second case. + /// + /// You can access the encoding map (OAS 'encoding' property) as the `Content` + /// type's `encodingMap` as well. + /// + /// You can access the positional encoding (OAS 'prefixEncoding' and + /// `itemEncoding` properties) as the `Content` type's `prefixEncoding` + /// and `itemEncoding` properties. + public var encoding: Either, PositionalEncoding>? /// Dictionary of vendor extensions. /// @@ -24,18 +45,87 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// The encoding of this `Content` (Media Type Object) if it is a map + /// from property names to encoding information. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `prefixEncoding` and `itemEncoding` properties. + public var encodingMap: OrderedDictionary? { encoding?.a } + + /// The positional prefix-encoding for this `Content` (Media Type + /// Object) if set. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `itemEncoding` and `encodingMap` properties. + public var prefixEncoding: [Encoding]? { encoding?.b?.prefixEncoding } + + /// The positional item-encoding for this `Content` (Media Type + /// Object) if set. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `prefixEncoding` and `encodingMap` properties. + public var itemEncoding: Encoding? { encoding?.b?.itemEncoding } + /// Create `Content` with a schema, a reference to a schema, or no /// schema at all and optionally provide a single example. public init( schema: JSONSchema?, + itemSchema: JSONSchema? = nil, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema + self.example = example + self.examples = nil + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema, a reference to a schema, or no + /// schema at all and optionally provide a single example. + public init( + schema: JSONSchema?, + itemSchema: JSONSchema? = nil, + example: AnyCodable? = nil, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = schema + self.itemSchema = itemSchema + self.example = example + self.examples = nil + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema, a reference to a schema, or no + /// schema at all and optionally provide a single example. + public init( + itemSchema: JSONSchema?, + example: AnyCodable? = nil, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema self.example = example self.examples = nil - self.encoding = encoding + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } self.vendorExtensions = vendorExtensions } @@ -50,7 +140,7 @@ extension OpenAPI { self.schema = .reference(schemaReference.jsonReference, description: schemaReference.description) self.example = example self.examples = nil - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -58,14 +148,16 @@ extension OpenAPI { /// example. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema self.example = example self.examples = nil - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -89,7 +181,7 @@ extension OpenAPI { } self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -104,7 +196,7 @@ extension OpenAPI { self.schema = .reference(schemaReference.jsonReference) self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -112,14 +204,53 @@ extension OpenAPI { /// of examples. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, examples: Example.Map?, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema + self.examples = examples + self.example = examples.flatMap(Self.firstExample(from:)) + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema and optionally provide a map + /// of examples. + public init( + itemSchema: JSONSchema?, + examples: Example.Map?, + encoding: OrderedDictionary? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema + self.examples = examples + self.example = examples.flatMap(Self.firstExample(from:)) + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema and optionally provide a map + /// of examples. + public init( + itemSchema: JSONSchema? = nil, + examples: Example.Map?, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } self.vendorExtensions = vendorExtensions } } @@ -159,6 +290,7 @@ extension OpenAPI.Content: Encodable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(schema, forKey: .schema) + try container.encodeIfPresent(itemSchema, forKey: .itemSchema) // only encode `examples` if non-nil, // otherwise encode `example` if non-nil @@ -168,7 +300,18 @@ extension OpenAPI.Content: Encodable { try container.encode(example, forKey: .example) } - try container.encodeIfPresent(encoding, forKey: .encoding) + if let encoding { + switch encoding { + case .a(let encoding): + try container.encode(encoding, forKey: .encoding) + + case .b(let positionalEncoding): + if !positionalEncoding.prefixEncoding.isEmpty { + try container.encode(positionalEncoding.prefixEncoding, forKey: .prefixEncoding) + } + try container.encodeIfPresent(positionalEncoding.itemEncoding, forKey: .itemEncoding) + } + } if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) @@ -188,9 +331,27 @@ extension OpenAPI.Content: Decodable { ) } + guard !(container.contains(.encoding) && (container.contains(.prefixEncoding) || container.contains(.itemEncoding))) else { + throw GenericError( + subjectName: "Encoding and Positional Encoding", + details: "If `prefixEncoding` or `itemEncoding` are specified then `encoding` is not allowed in the Media Type Object (`OpenAPI.Content`).", + codingPath: container.codingPath + ) + } + schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) + itemSchema = try container.decodeIfPresent(JSONSchema.self, forKey: .itemSchema) + + if container.contains(.encoding) { + encoding = .a(try container.decode(OrderedDictionary.self, forKey: .encoding)) + } else if container.contains(.prefixEncoding) || container.contains(.itemEncoding) { + let prefixEncoding = try container.decodeIfPresent([Encoding].self, forKey: .prefixEncoding) ?? [] + let itemEncoding = try container.decodeIfPresent(Encoding.self, forKey: .itemEncoding) - encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) + encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + encoding = nil + } if container.contains(.example) { example = try container.decode(AnyCodable.self, forKey: .example) @@ -208,13 +369,24 @@ extension OpenAPI.Content: Decodable { extension OpenAPI.Content { internal enum CodingKeys: ExtendableCodingKey { case schema + case itemSchema case example // `example` and `examples` are mutually exclusive case examples // `example` and `examples` are mutually exclusive case encoding + case itemEncoding + case prefixEncoding case extended(String) static var allBuiltinKeys: [CodingKeys] { - return [.schema, .example, .examples, .encoding] + return [ + .schema, + .itemSchema, + .example, + .examples, + .encoding, + .itemEncoding, + .prefixEncoding + ] } static func extendedKey(for value: String) -> CodingKeys { @@ -225,12 +397,18 @@ extension OpenAPI.Content { switch stringValue { case "schema": self = .schema + case "itemSchema": + self = .itemSchema case "example": self = .example case "examples": self = .examples case "encoding": self = .encoding + case "itemEncoding": + self = .itemEncoding + case "prefixEncoding": + self = .prefixEncoding default: self = .extendedKey(for: stringValue) } @@ -240,12 +418,18 @@ extension OpenAPI.Content { switch self { case .schema: return "schema" + case .itemSchema: + return "itemSchema" case .example: return "example" case .examples: return "examples" case .encoding: return "encoding" + case .itemEncoding: + return "itemEncoding" + case .prefixEncoding: + return "prefixEncoding" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift b/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift new file mode 100644 index 000000000..ab9a4f544 --- /dev/null +++ b/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift @@ -0,0 +1,41 @@ +// +// ContentPositionalEncoding.swift +// +// +// Created by Mathew Polzin on 12/29/19. +// + +import OpenAPIKitCore + +extension OpenAPI.Content { + /// OpenAPI Spec `itemEncoding` and `prefixEncoding` on the "Media Type Object" + /// + /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.2.0.html#media-type-object). + public struct PositionalEncoding: Equatable, Sendable { + + /// An array of positional encoding information, as defined under + /// [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `prefixEncoding` field **SHALL** only apply when the media type is + /// `multipart`. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var prefixEncoding: [OpenAPI.Content.Encoding] + + /// A single Encoding Object that provides encoding information for + /// multiple array items, as defined under [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `itemEncoding` field **SHALL** only apply when the media type + /// is multipart. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var itemEncoding: OpenAPI.Content.Encoding? + + public init( + prefixEncoding: [OpenAPI.Content.Encoding] = [], + itemEncoding: OpenAPI.Content.Encoding? = nil + ) { + self.prefixEncoding = prefixEncoding + self.itemEncoding = itemEncoding + } + } +} + diff --git a/Sources/OpenAPIKit/Content/DereferencedContent.swift b/Sources/OpenAPIKit/Content/DereferencedContent.swift index 5350b55ee..d122422e5 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContent.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContent.swift @@ -14,9 +14,14 @@ import OpenAPIKitCore public struct DereferencedContent: Equatable { public let underlyingContent: OpenAPI.Content public let schema: DereferencedJSONSchema? + public let itemSchema: DereferencedJSONSchema? public let examples: OrderedDictionary? public let example: AnyCodable? - public let encoding: OrderedDictionary? + public let encoding: Either, DereferencedPositionalEncoding>? + + public var encodingMap: OrderedDictionary? { encoding?.a } + public var prefixEncoding: [DereferencedContentEncoding]? { encoding?.b?.prefixEncoding } + public var itemEncoding: DereferencedContentEncoding? { encoding?.b?.itemEncoding } public subscript(dynamicMember path: KeyPath) -> T { return underlyingContent[keyPath: path] @@ -35,6 +40,7 @@ public struct DereferencedContent: Equatable { following references: Set ) throws { self.schema = try content.schema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) + self.itemSchema = try content.itemSchema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) let examples = try content.examples? .mapValues { try $0._dereferenced( @@ -48,10 +54,17 @@ public struct DereferencedContent: Equatable { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) ?? content.example - self.encoding = try content.encoding.map { encodingMap in - try encodingMap.mapValues { encoding in + switch content.encoding { + case .a(let encodingMap): + self.encoding = .a(try encodingMap.mapValues { encoding in try encoding._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) - } + }) + case .b(let positionalEncoding): + let prefixEncoding = try positionalEncoding.prefixEncoding.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let itemEncoding = try positionalEncoding.itemEncoding.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + case nil: + self.encoding = nil } self.underlyingContent = content @@ -79,8 +92,10 @@ extension OpenAPI.Content: LocallyDereferenceable { extension OpenAPI.Content: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { let oldSchema = schema + let oldItemSchema = itemSchema async let (newSchema, c1, m1) = oldSchema.externallyDereferenced(with: loader) + async let (newItemSchema, c2, m2) = oldItemSchema.externallyDereferenced(with: loader) var newContent = self var newComponents = try await c1 @@ -88,18 +103,33 @@ extension OpenAPI.Content: ExternallyDereferenceable { newContent.schema = try await newSchema + try await newComponents.merge(c2) + newMessages += try await m2 + newContent.itemSchema = try await newItemSchema + if let oldExamples = examples { - let (newExamples, c2, m2) = try await oldExamples.externallyDereferenced(with: loader) + let (newExamples, c3, m3) = try await oldExamples.externallyDereferenced(with: loader) newContent.examples = newExamples - try newComponents.merge(c2) - newMessages += m2 + try newComponents.merge(c3) + newMessages += m3 } if let oldEncoding = encoding { - let (newEncoding, c3, m3) = try await oldEncoding.externallyDereferenced(with: loader) - newContent.encoding = newEncoding - try newComponents.merge(c3) - newMessages += m3 + switch oldEncoding { + case .a(let oldEncoding): + let (newEncoding, c4, m4) = try await oldEncoding.externallyDereferenced(with: loader) + newContent.encoding = .a(newEncoding) + try newComponents.merge(c4) + newMessages += m4 + + case .b(let oldPositionalEncoding): + async let (newItemEncoding, c4, m4) = try oldPositionalEncoding.itemEncoding.externallyDereferenced(with: loader) + async let (newPrefixEncoding, c5, m5) = try oldPositionalEncoding.prefixEncoding.externallyDereferenced(with: loader) + newContent.encoding = try await .b(.init(prefixEncoding: newPrefixEncoding, itemEncoding: newItemEncoding)) + try await newComponents.merge(c4) + try await newComponents.merge(c5) + newMessages += try await m4 + m5 + } } return (newContent, newComponents, newMessages) diff --git a/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift b/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift new file mode 100644 index 000000000..11226ea2f --- /dev/null +++ b/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift @@ -0,0 +1,38 @@ +// +// DereferencedContentPositionalEncoding.swift +// +// +// Created by Mathew Polzin on 12/29/19. +// + +import OpenAPIKitCore + +/// OpenAPI Spec `itemEncoding` and `prefixEncoding` on the "Media Type Object" +/// +/// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.2.0.html#media-type-object). +public struct DereferencedPositionalEncoding: Equatable { + + /// An array of positional encoding information, as defined under + /// [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `prefixEncoding` field **SHALL** only apply when the media type is + /// `multipart`. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var prefixEncoding: [DereferencedContentEncoding] + + /// A single Encoding Object that provides encoding information for + /// multiple array items, as defined under [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `itemEncoding` field **SHALL** only apply when the media type + /// is multipart. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var itemEncoding: DereferencedContentEncoding? + + public init( + prefixEncoding: [DereferencedContentEncoding] = [], + itemEncoding: DereferencedContentEncoding? = nil + ) { + self.prefixEncoding = prefixEncoding + self.itemEncoding = itemEncoding + } +} diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index bbc414460..4fb851c6f 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -71,6 +71,13 @@ extension Either where A == DereferencedSchemaContext { } } +extension Either where A == OrderedDictionary { + + public var mapValue: A? { + a + } +} + extension Either where B == OpenAPI.PathItem { /// Retrieve the path item if that is what this property contains. public var pathItemValue: B? { b } @@ -141,6 +148,10 @@ extension Either where B == OpenAPI.SecurityScheme { public var securitySchemeValue: B? { b } } +extension Either where B == OpenAPI.Content.PositionalEncoding { + public var positionalValue: B? { b } +} + // MARK: - Convenience constructors extension Either where A == Bool { /// Construct a boolean value. diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 029f135c4..0103e4eb5 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1112,6 +1112,7 @@ fileprivate func assertEqualNewToOld(_ newContentMap: OpenAPIKit.OpenAPI.Content XCTAssertNil(oldContent.examples) } if let newEncodingRef = newContent.encoding { + let newEncodingRef = try XCTUnwrap(newEncodingRef.mapValue) let oldEncodingRef = try XCTUnwrap(oldContent.encoding) for ((newKey, newEncoding), (oldKey, oldEncoding)) in zip(newEncodingRef, oldEncodingRef) { XCTAssertEqual(newKey, oldKey) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c38519969..3d10fbe94 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -44,6 +44,21 @@ final class ContentTests: XCTestCase { XCTAssertEqual(withExamples.example?.value as? String, "world") XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + let withExamples2 = OpenAPI.Content( + itemSchema: .string, + examples: [ + "hello": .example(.init(value: .init("world"))), + "bbbb": .example(.init(value: .b("pick me"))), + "aaaa": .example(.init(value: .a(URL(string: "http://google.com")!))) + ] + ) + XCTAssertEqual(withExamples2.itemSchema, .string) + XCTAssertNotNil(withExamples2.examples) + // we expect the example to be the first example where ordering + // is the order in which the examples are given: + XCTAssertEqual(withExamples2.example?.value as? String, "world") + XCTAssertEqual(withExamples2.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + let t4 = OpenAPI.Content( schemaReference: .external(URL(string: "hello.json#/world")!), examples: nil @@ -69,6 +84,34 @@ final class ContentTests: XCTestCase { ) ] ) + + let withPrefixEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init()] + ) + + XCTAssertNil(withPrefixEncoding.schema) + XCTAssertEqual(withPrefixEncoding.itemSchema, .string) + XCTAssertEqual(withPrefixEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: nil)) + + let withItemEncoding = OpenAPI.Content( + itemSchema: .string, + itemEncoding: .init() + ) + + XCTAssertNil(withItemEncoding.schema) + XCTAssertEqual(withItemEncoding.itemSchema, .string) + XCTAssertEqual(withItemEncoding.encoding?.positionalValue, .init(itemEncoding: .init())) + + let withPrefixAndItemEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init()], + itemEncoding: .init() + ) + + XCTAssertNil(withPrefixAndItemEncoding.schema) + XCTAssertEqual(withPrefixAndItemEncoding.itemSchema, .string) + XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) } func test_contentMap() { @@ -172,6 +215,72 @@ extension ContentTests { XCTAssertEqual(content, OpenAPI.Content(schema: .init(.string))) } + func test_itemSchemaContent_encode() { + let content = OpenAPI.Content(itemSchema: .string) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemSchema" : { + "type" : "string" + } + } + """ + ) + } + + func test_itemSchemaContent_decode() { + let contentData = + """ + { + "itemSchema" : { + "type" : "string" + } + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual(content, OpenAPI.Content(itemSchema: .init(.string))) + } + + func test_schemaAndItemSchemaContent_encode() { + let content = OpenAPI.Content(schema: .string, itemSchema: .string) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemSchema" : { + "type" : "string" + }, + "schema" : { + "type" : "string" + } + } + """ + ) + } + + func test_schemaAndItemSchemaContent_decode() { + let contentData = + """ + { + "itemSchema" : { + "type" : "string" + }, + "schema" : { + "type" : "string" + } + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual(content, OpenAPI.Content(schema: .string, itemSchema: .init(.string))) + } + func test_schemalessContent_encode() { let content = OpenAPI.Content(schema: nil, example: "hello world") let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) @@ -347,6 +456,40 @@ extension ContentTests { XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) } + func test_decodeFailureForBothEncodingAndItemEncoding() { + let contentData = + """ + { + "encoding" : { + "json" : { + "contentType" : "application\\/json" + } + }, + "itemEncoding" : { + "json" : { + "contentType" : "application\\/json" + } + } + } + """.data(using: .utf8)! + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) + } + + func test_decodeFailureForBothEncodingAndPrefixEncoding() { + let contentData = + """ + { + "encoding" : { + "json" : { + "contentType" : "application\\/json" + } + }, + "prefixEncoding" : [] + } + """.data(using: .utf8)! + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) + } + func test_encodingAndSchema_encode() { let content = OpenAPI.Content( schema: .init(.string), @@ -397,6 +540,63 @@ extension ContentTests { ) } + func test_prefixAndItemEncodingAndItemSchema_encode() { + let content = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init(contentTypes: [.json])], + itemEncoding: .init(contentTypes: [.json]) + ) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemEncoding" : { + "contentType" : "application\\/json" + }, + "itemSchema" : { + "type" : "string" + }, + "prefixEncoding" : [ + { + "contentType" : "application\\/json" + } + ] + } + """ + ) + } + + func test_prefixAndItemEncodingAndItemSchema_decode() { + let contentData = + """ + { + "itemEncoding" : { + "contentType" : "application\\/json" + }, + "itemSchema" : { + "type" : "string" + }, + "prefixEncoding" : [ + { + "contentType" : "application\\/json" + } + ] + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual( + content, + OpenAPI.Content( + itemSchema: .init(.string), + prefixEncoding: [.init(contentTypes: [.json])], + itemEncoding: .init(contentTypes: [.json]) + ) + ) + } + func test_vendorExtensions_encode() { let content = OpenAPI.Content( schema: .init(.string), diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index bf4ae9bb2..03caa192b 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -125,8 +125,8 @@ final class DereferencedContentTests: XCTestCase { func test_inlineEncoding() throws { let t1 = try OpenAPI.Content(schema: .string, encoding: ["test": .init()]).dereferenced(in: .noComponents) - XCTAssertNotNil(t1.encoding?["test"]) - XCTAssertNil(t1.encoding?["test"]?.headers) + XCTAssertNotNil(t1.encodingMap?["test"]) + XCTAssertNil(t1.encodingMap?["test"]?.headers) } func test_referencedHeaderInEncoding() throws { @@ -146,11 +146,11 @@ final class DereferencedContentTests: XCTestCase { ] ).dereferenced(in: components) XCTAssertEqual( - t1.encoding?["test"]?.headers?["test"]?.schemaOrContent.schemaValue, + t1.encodingMap?["test"]?.headers?["test"]?.schemaOrContent.schemaValue, DereferencedJSONSchema.string(.init(), .init()) ) // just test that dynamic member lookup is connected correctly - XCTAssertEqual(t1.encoding?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) + XCTAssertEqual(t1.encodingMap?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) } func test_missingHeaderInEncoding() { From 43f6eebd31fe1833771f23ee2ce7a808d1d08617 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 21 Nov 2025 19:44:25 -0600 Subject: [PATCH 34/73] add a bit more test coverage --- .../Content/ContentTests.swift | 21 +++++++++++ .../Content/DereferencedContentTests.swift | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index 3d10fbe94..c95ef9337 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -112,6 +112,27 @@ final class ContentTests: XCTestCase { XCTAssertNil(withPrefixAndItemEncoding.schema) XCTAssertEqual(withPrefixAndItemEncoding.itemSchema, .string) XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) + XCTAssertEqual(withPrefixAndItemEncoding.prefixEncoding, [.init()]) + XCTAssertEqual(withPrefixAndItemEncoding.itemEncoding, .init()) + + XCTAssertEqual( + OpenAPI.Content( + schema: .string, + prefixEncoding: [], + itemEncoding: nil + ), + OpenAPI.Content( + schema: .string, + encoding: nil + ) + ) + + let emptyPositionalEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [], + itemEncoding: nil + ) + XCTAssertEqual(emptyPositionalEncoding.encoding, nil) } func test_contentMap() { diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index 03caa192b..07f6f0a8e 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -123,6 +123,27 @@ final class DereferencedContentTests: XCTestCase { ) } + func test_inlineItemSchema() throws { + let t1 = try OpenAPI.Content(itemSchema: .string).dereferenced(in: .noComponents) + XCTAssertEqual(t1.itemSchema, .string(.init(), .init())) + } + + func test_referencedItemSchema() throws { + let components = OpenAPI.Components( + schemas: ["schema1": .string] + ) + let t1 = try OpenAPI.Content(itemSchema: .reference(.component(named: "schema1"))).dereferenced(in: components) + XCTAssertEqual(t1.itemSchema, .string(.init(vendorExtensions: ["x-component-name": "schema1"]), .init())) + } + + func test_missingItemSchema() { + XCTAssertThrowsError( + try OpenAPI.Content( + itemSchema: .reference(.component(named: "missing")) + ).dereferenced(in: .noComponents) + ) + } + func test_inlineEncoding() throws { let t1 = try OpenAPI.Content(schema: .string, encoding: ["test": .init()]).dereferenced(in: .noComponents) XCTAssertNotNil(t1.encodingMap?["test"]) @@ -153,6 +174,20 @@ final class DereferencedContentTests: XCTestCase { XCTAssertEqual(t1.encodingMap?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) } + func test_inlinePrefixEncoding() throws { + let t1 = try OpenAPI.Content(schema: .string, prefixEncoding: [.init()]).dereferenced(in: .noComponents) + XCTAssertNil(t1.encodingMap?["test"]) + XCTAssertEqual(t1.prefixEncoding?.count, 1) + XCTAssertNil(t1.itemEncoding) + } + + func test_inlineItemEncoding() throws { + let t1 = try OpenAPI.Content(schema: .string, itemEncoding: .init()).dereferenced(in: .noComponents) + XCTAssertNil(t1.encodingMap?["test"]) + XCTAssertEqual(t1.prefixEncoding, []) + XCTAssertNotNil(t1.itemEncoding) + } + func test_missingHeaderInEncoding() { XCTAssertThrowsError( try OpenAPI.Content( From 2e382313819758ed836e0e4210813dc7de1af00b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 22 Nov 2025 20:27:40 -0600 Subject: [PATCH 35/73] add missing tests for HttpMethod core type --- .../OpenAPIKitCoreTests/HttpMethodTests.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Tests/OpenAPIKitCoreTests/HttpMethodTests.swift diff --git a/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift b/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift new file mode 100644 index 000000000..66ff38332 --- /dev/null +++ b/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift @@ -0,0 +1,125 @@ +// +// HttpTypeTests.swift +// + +import OpenAPIKitCore +import XCTest + +final class HttpMethodTests: XCTestCase { + func test_builtinInits() { + let methods: [Shared.HttpMethod] = [ + .get, + .post, + .patch, + .put, + .delete, + .head, + .options, + .trace, + .query + ] + + XCTAssertEqual(methods, [ + .builtin(.get), + .builtin(.post), + .builtin(.patch), + .builtin(.put), + .builtin(.delete), + .builtin(.head), + .builtin(.options), + .builtin(.trace), + .builtin(.query) + ]) + + XCTAssertEqual(methods.map(\.rawValue), [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY" + ]) + + XCTAssertEqual(methods, [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY" + ]) + + XCTAssertEqual(methods.map(Optional.some), [ + .init(rawValue: "GET"), + .init(rawValue: "POST"), + .init(rawValue: "PATCH"), + .init(rawValue: "PUT"), + .init(rawValue: "DELETE"), + .init(rawValue: "HEAD"), + .init(rawValue: "OPTIONS"), + .init(rawValue: "TRACE"), + .init(rawValue: "QUERY") + ]) + } + + func test_otherInit() { + let otherMethod = Shared.HttpMethod.other("LINK") + XCTAssertEqual(otherMethod, Shared.HttpMethod(rawValue: "LINK")) + XCTAssertEqual(otherMethod, "LINK") + XCTAssertEqual(otherMethod.rawValue, "LINK") + } + + func test_knownBadCasing() { + XCTAssertNil(Shared.HttpMethod(rawValue: "link")) + XCTAssertEqual(Shared.HttpMethod.other("link"), "link") + XCTAssertEqual(Shared.HttpMethod.problem(with: "link"), "'link' must be uppercased") + } + + func test_encoding() throws { + let methods: [Shared.HttpMethod] = [ + .get, + .post, + .patch, + .put, + .delete, + .head, + .options, + .trace, + .query, + "LINK" + ] + + for method in methods { + let encoded = try orderUnstableTestStringFromEncoding(of: method) + + XCTAssertEqual(encoded, "\"\(method.rawValue)\"") + } + } + + func test_decoding() throws { + let methods: [String] = [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY", + "LINK" + ] + + for method in methods { + let decoded = try orderUnstableDecode(Shared.HttpMethod.self, from: "\"\(method)\"".data(using: .utf8)!) + + XCTAssertEqual(decoded, Shared.HttpMethod(rawValue: method)) + } + } +} From e4b4b3534055c2b512b5d0daf98e0a32fc36154e Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 23 Nov 2025 20:03:00 -0600 Subject: [PATCH 36/73] update migration guide --- .../migration_guides/v5_migration_guide.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 096fac59d..245a119ec 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -215,6 +215,84 @@ 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>`. +### Media Type Object (`OpenAPI.Content`) +#### Schema property +The `schema` property has changed from either an `OpenAPI.Reference` or a +`JOSNSchema` to just a `JSONSchema`. This both reflects the specification and +also works just as well because `JSONSchema` has its own `.reference` case. +However, this does result in some necessary code changes. + +You now have one fewer layer to traverse to get to a schema. +```swift +/// BEFORE +let JSONSchema? = content[.json]?.schema?.schemaValue + +/// AFTER +let JSONSchema? = content[.json]?.schema +``` + +Switches over the `schema` now should look directly at the `JSONSchema` `value` +to switch on whether the `schema` is a reference or not. +```swift +/// BEFORE +guard let schema = content[.json]?.schema else { return } +switch schema { +case .a(let reference): + print(reference) +case .b(let schema): + print(schema) +} + +/// AFTER +guard let schema = content[.json]?.schema else { return } +switch schema.value { +case .reference(let reference, _): + print(reference) +default: + print(schema) +} +``` + +The `OpenAPI.Content(schema:example:encoding:vendorExtensions:)` initializer +takes a schema directly so if you were passing in a schema anyway you just +remove one layer of wrapping (the `Either` that was previously there). If you +were passing in a reference, just make sure you are using the `JSONSchema` +`reference()` convenience constructor where it would have previously been the +`Either` `reference()` convenience constructor; they should be source-code +compatible. + +#### Encoding property +The `OpenAPI.Content` `encoding` property has changed from being a map of +encodings (`OrderedDictionary`) to an `Either` in order to +support the new OAS 3.2.0 `prefixEncoding` and `itemEncoding` options which are +mutually exclusive with the existing map of encodings. + +Anywhere you read the `encoding` property in your existing code, you can switch +to the `encodingMap` property if you want a short term solution that compiles +and behaves the same way for any OpenAPI Documents that do not use the new +positional encoding properties. +```swift +/// BEFORE +let encoding: Encoding? = content.encoding + +/// AFTER +let encoding: Encoding? = content.encodingMap +``` + +If you wish to handle the new encoding options, you will need to switch over the +`Either` or otherwise handle the additional `prefixEncoding` and `itemEncoding` +properties. +```swift +guard let encoding = content.encoding else { return } +switch encoding { +case .a(let encodingMap): + print(encodingMap) +case .b(let positionalEncoding): + print(positionalEncoding.prefixEncoding) + print(positionalEncoding.itemEncoding) +} +``` + ### 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 From f54d617ac7eeca9044a7023a00a6bbf8b316f7df Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 23 Nov 2025 20:21:39 -0600 Subject: [PATCH 37/73] shore up specification coverage file --- documentation/specification_coverage.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/documentation/specification_coverage.md b/documentation/specification_coverage.md index 92af640d8..d2ad6e125 100644 --- a/documentation/specification_coverage.md +++ b/documentation/specification_coverage.md @@ -38,6 +38,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### OpenAPI Object (`OpenAPI.Document`) - [x] openapi (`openAPIVersion`) +- [x] $self (`selfRUI`) - [x] info - [ ] jsonSchemaDialect - [x] servers @@ -72,7 +73,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] specification extensions (`vendorExtensions`) ### Server Object (`OpenAPI.Server`) -- [x] url +- [x] url (`urlTemplate`) - [x] description - [x] variables - [x] specification extensions (`vendorExtensions`) @@ -140,18 +141,18 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Parameter Object (`OpenAPI.Parameter`) - [x] name -- [x] in (`context`) +- [x] in (`context.location`) - [x] description -- [x] required (part of `context`) +- [x] required (`context.required`) - [x] deprecated - [x] allowEmptyValue (part of `context`) -- [x] schema (`schemaOrContent`) +- [x] schema (`schemaOrContent` in relevant `context` cases) - [x] style - [x] explode - [x] allowReserved - [x] example - [x] examples -- [x] content (`schemaOrContent`) +- [x] content (`schemaOrContent` in relevant `context` cases) - [x] specification extensions (`vendorExtensions`) ### Request Body Object (`OpenAPI.Request`) @@ -162,9 +163,12 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Media Type Object (`OpenAPI.Content`) - [x] schema +- [x] itemSchema - [x] example - [x] examples -- [x] encoding +- [x] encoding (`encoding` first case) +- [x] prefixEncoding (part of `encoding` second case) +- [x] itemEncoding (part of `encoding` second case) - [x] specification extensions (`vendorExtensions`) ### Encoding Object (`OpenAPI.Content.Encoding`) @@ -173,7 +177,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] style - [x] explode - [x] allowReserved -- [ ] specification extensions +- [x] specification extensions ### Responses Object (`OpenAPI.Response.Map`) - [x] default (`Response.StatusCode.Code` `.default` case) @@ -181,6 +185,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - ~[ ] specification extensions~ (not a planned addition) ### Response Object (`OpenAPI.Response`) +- [x] summary - [x] description - [x] headers - [x] content @@ -240,7 +245,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] remote (different file) reference (`external` case) - [x] encode - [x] decode - - [ ] dereference + - [x] dereference ### Schema Object (`JSONSchema`) - [x] Mostly complete support for JSON Schema inherited keywords (select ones enumerated below) From 6d677587f5f5dd3347bcd59f7bb7c875c5395fe8 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 24 Nov 2025 10:15:33 -0600 Subject: [PATCH 38/73] shore up test coverage of content and externally dereferenced content --- .../Content/ContentTests.swift | 24 +++++++++- .../ExternalDereferencingDocumentTests.swift | 44 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c95ef9337..c6b92ae75 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -71,7 +71,7 @@ final class ContentTests: XCTestCase { ) XCTAssertNil(t5.schema?.reference) - let _ = OpenAPI.Content( + let withEncodingMap = OpenAPI.Content( schema: .init(.string), example: nil, encoding: [ @@ -84,6 +84,16 @@ final class ContentTests: XCTestCase { ) ] ) + XCTAssertEqual( + withEncodingMap.encodingMap?["hello"], + .init( + contentTypes: [.json], + headers: [ + "world": .init(OpenAPI.Header(schemaOrContent: .init(.header(.string)))) + ], + allowReserved: true + ) + ) let withPrefixEncoding = OpenAPI.Content( itemSchema: .string, @@ -133,6 +143,18 @@ final class ContentTests: XCTestCase { itemEncoding: nil ) XCTAssertEqual(emptyPositionalEncoding.encoding, nil) + + let emptyPositionalEncoding2 = OpenAPI.Content( + itemSchema: .string, + examples: ["hi": .example(summary: "hi example")], + prefixEncoding: [], + itemEncoding: nil + ) + XCTAssertEqual(emptyPositionalEncoding2.encoding, nil) + XCTAssertEqual(emptyPositionalEncoding2.encodingMap, nil) + XCTAssertEqual(emptyPositionalEncoding2.prefixEncoding, nil) + XCTAssertEqual(emptyPositionalEncoding2.itemEncoding, nil) + XCTAssertNotNil(emptyPositionalEncoding2.examples) } func test_contentMap() { diff --git a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift index 4f977a3d2..bcb18f564 100644 --- a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift @@ -74,6 +74,41 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "type": "object" } """, + "requests_hello_json": """ + { + "content": { + "application/json": { + "itemSchema": { + "type": "object", + "properties": { + "body": { + "$ref": "file://./schemas/string_param.json" + } + } + }, + "prefixEncoding": [ + { + "style": "form" + } + ], + "itemEncoding": { + "headers": { + "head1": { + "$ref": "file://./headers/hello.json" + } + } + } + } + } + } + """, + "headers_hello_json": """ + { + "schema": { + "$ref": "file://./schemas/string_param.json" + } + } + """, "paths_webhook_json": """ { "summary": "just a webhook", @@ -220,6 +255,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { ], get: .init( operationId: "helloOp", + requestBody: .reference(.external(URL(string: "file://./requests/hello.json")!)), responses: [:], callbacks: [ "callback1": .reference(.external(URL(string: "file://./callbacks/one.json")!)) @@ -248,6 +284,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted + // do 4 times var docCopy1 = document try await docCopy1.externallyDereference(with: ExampleLoader.self) try await docCopy1.externallyDereference(with: ExampleLoader.self) @@ -255,14 +292,17 @@ final class ExternalDereferencingDocumentTests: XCTestCase { try await docCopy1.externallyDereference(with: ExampleLoader.self) docCopy1.components.sort() + // do to depth of 4 var docCopy2 = document try await docCopy2.externallyDereference(with: ExampleLoader.self, depth: 4) docCopy2.components.sort() + // do until done var docCopy3 = document let messages = try await docCopy3.externallyDereference(with: ExampleLoader.self, depth: .full) docCopy3.components.sort() + // for this document, depth of 4 is enough for all the above to compare equally XCTAssertEqual(docCopy1, docCopy2) XCTAssertEqual(docCopy2, docCopy3) @@ -270,6 +310,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { messages.sorted(), ["file://./callbacks/one.json", "file://./examples/good.json", + "file://./headers/hello.json", "file://./headers/webhook.json", "file://./headers/webhook2.json", "file://./links/first.json", @@ -278,6 +319,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./paths/callback.json", "file://./paths/webhook.json", "file://./paths/webhook.json", + "file://./requests/hello.json", "file://./requests/webhook.json", "file://./responses/webhook.json", "file://./schemas/basic_object.json", @@ -285,6 +327,8 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./schemas/string_param.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", + "file://./schemas/string_param.json", + "file://./schemas/string_param.json", "file://./schemas/string_param.json#"] ) } From 708777f29e14b22947acc039916dbc4831ed9700 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:23:35 -0600 Subject: [PATCH 39/73] add a bit of missing document test coverage --- .../Document/DocumentTests.swift | 13 +++++++++++++ .../OpenAPIKitTests/Document/DocumentTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift b/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift index 761b7f398..c0e81cbe9 100644 --- a/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift @@ -202,6 +202,19 @@ final class DocumentTests: XCTestCase { XCTAssertEqual(t.allServers, []) } + func test_allServersExternalPathItem() { + let t = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello/world": .reference(.external(URL(string: "file://./hello_world.json")!)) + ], + components: .noComponents + ) + + XCTAssertEqual(t.allServers, []) + } + func test_allServers_onlyRoot() { let s1 = OpenAPI.Server(url: URL(string: "https://website.com")!) let s2 = OpenAPI.Server(url: URL(string: "https://website2.com")!) diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 18d493db8..afd10edcf 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -483,6 +483,22 @@ final class DocumentTests: XCTestCase { XCTAssertNoThrow(try orderUnstableDecode(OpenAPI.Document.self, from: docData)) } + + func test_initVersionWrongNumberOfComponents() { + XCTAssertNil(OpenAPI.Document.Version(rawValue: "")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2.3.4")) + } + + func test_initPatchVersionNotInteger() { + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2.a")) + } + + func test_versionOutsideKnownBoundsStillSerializesToString() { + XCTAssertEqual(OpenAPI.Document.Version.v3_1_x(x: 1000).rawValue, "3.1.1000") + XCTAssertEqual(OpenAPI.Document.Version.v3_2_x(x: 1000).rawValue, "3.2.1000") + } } // MARK: - Codable From 22b0a7c9aea605926b9e01c99f467cd7bf576a32 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:26:41 -0600 Subject: [PATCH 40/73] add support for additional common sequential media types --- .../OpenAPIKitCore/Shared/ContentType.swift | 36 ++++++++++++++++--- .../ContentTypeTests.swift | 4 +++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIKitCore/Shared/ContentType.swift b/Sources/OpenAPIKitCore/Shared/ContentType.swift index ab70fa763..feb7eb684 100644 --- a/Sources/OpenAPIKitCore/Shared/ContentType.swift +++ b/Sources/OpenAPIKitCore/Shared/ContentType.swift @@ -107,21 +107,30 @@ public extension Shared.ContentType { static let csv: Self = .init(.csv) static let doc: Self = .init(.doc) static let docx: Self = .init(.docx) + /// Event Stream (e.g. Server-Sent Events) + static let eventStream: Self = .init(.eventStream) /// URL-encoded form data. See also: `multipartForm`. static let form: Self = .init(.form) /// Graphics Interchange Format static let gif: Self = .init(.gif) /// geojson as defined in GeoJSON standard (RFC 7946) - /// /// see: https://datatracker.ietf.org/doc/html/rfc7946#section-12 static let geojson: Self = .init(.geojson) static let html: Self = .init(.html) static let javascript: Self = .init(.javascript) /// JPEG image static let jpg: Self = .init(.jpg) + /// JSON + /// See https://www.rfc-editor.org/rfc/rfc8259.html static let json: Self = .init(.json) /// JSON:API Document static let jsonapi: Self = .init(.jsonapi) + /// JSON Lines + /// See https://jsonlines.org + static let jsonl: Self = .init(.jsonl) + /// json-seq (text sequences) + /// See https://www.rfc-editor.org/rfc/rfc7464.html + static let json_seq: Self = .init(.json_seq) /// Quicktime video static let mov: Self = .init(.mov) /// MP3 audio @@ -132,13 +141,14 @@ public extension Shared.ContentType { static let mpg: Self = .init(.mpg) /// Multipart form data. See also: `form`. static let multipartForm: Self = .init(.multipartForm) + /// Multipart mixed data. + static let multipartMixed: Self = .init(.multipartMixed) /// OpenType font static let otf: Self = .init(.otf) static let pdf: Self = .init(.pdf) /// PNG image static let png: Self = .init(.png) /// Protocol Buffers - /// /// See: https://protobuf.dev/ static let protobuf: Self = .init(.protobuf) /// RAR archive @@ -194,10 +204,11 @@ extension Shared.ContentType { case csv case doc case docx + /// Event Stream (e.g. Server-Sent Events) + case eventStream /// URL-encoded form data. See also: `multipartForm`. case form /// geojson as defined in GeoJSON standard (RFC 7946) - /// /// see: https://datatracker.ietf.org/doc/html/rfc7946#section-12 case geojson /// Graphics Interchange Format @@ -206,9 +217,17 @@ extension Shared.ContentType { case javascript /// JPEG image case jpg + /// JSON + /// See https://www.rfc-editor.org/rfc/rfc8259.html case json /// JSON:API Document case jsonapi + /// JSON Lines + /// See https://jsonlines.org + case jsonl + /// json-seq (text sequences) + /// See https://www.rfc-editor.org/rfc/rfc7464.html + case json_seq /// Quicktime video case mov /// MP3 audio @@ -219,13 +238,14 @@ extension Shared.ContentType { case mpg /// Multipart form data. See also: `form`. case multipartForm + /// Multipart mixed data. + case multipartMixed /// OpenType font case otf case pdf /// PNG image case png /// Protocol Buffers - /// /// See: https://protobuf.dev/ case protobuf /// RAR archive @@ -277,6 +297,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .csv: return "text/csv" case .doc: return "application/msword" case .docx: return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case .eventStream: return "text/event-stream" case .form: return "application/x-www-form-urlencoded" case .geojson: return "application/geo+json" case .gif: return "image/gif" @@ -285,11 +306,14 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .jpg: return "image/jpeg" case .json: return "application/json" case .jsonapi: return "application/vnd.api+json" + case .jsonl: return "application/jsonl" + case .json_seq: return "application/json-seq" case .mov: return "video/quicktime" case .mp3: return "audio/mpeg" case .mp4: return "video/mp4" case .mpg: return "video/mpeg" case .multipartForm: return "multipart/form-data" + case .multipartMixed: return "multipart/mixed" case .otf: return "font/otf" case .pdf: return "application/pdf" case .png: return "image/png" @@ -330,6 +354,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "text/csv": self = .csv case "application/msword": self = .doc case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": self = .docx + case "text/event-stream": self = .eventStream case "application/x-www-form-urlencoded": self = .form case "application/geo+json": self = .geojson case "image/gif": self = .gif @@ -338,11 +363,14 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "image/jpeg": self = .jpg case "application/json": self = .json case "application/vnd.api+json": self = .jsonapi + case "application/jsonl": self = .jsonl + case "application/json-seq": self = .json_seq case "video/quicktime": self = .mov case "audio/mpeg": self = .mp3 case "video/mp4": self = .mp4 case "video/mpeg": self = .mpg case "multipart/form-data": self = .multipartForm + case "multipart/mixed": self = .multipartMixed case "font/otf": self = .otf case "application/pdf": self = .pdf case "image/png": self = .png diff --git a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift index e4c201153..598d43956 100644 --- a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift +++ b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift @@ -26,6 +26,7 @@ final class ContentTypeTests: XCTestCase { .csv, .doc, .docx, + .eventStream, .form, .gif, .html, @@ -33,11 +34,14 @@ final class ContentTypeTests: XCTestCase { .jpg, .json, .jsonapi, + .jsonl, + .json_seq, .mov, .mp3, .mp4, .mpg, .multipartForm, + .multipartMixed, .otf, .pdf, .rar, From 53f3b8544aea91b6819c957a1f79dbb773e78cf5 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:47:48 -0600 Subject: [PATCH 41/73] Add conditional warnings for OAS 3.2.0 Media Type Object properties --- Sources/OpenAPIKit/Content/Content.swift | 71 ++++++++++++++++++- .../Content/ContentTests.swift | 5 ++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index f923b9510..d394250b6 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -11,7 +11,7 @@ extension OpenAPI { /// OpenAPI Spec "Media Type Object" /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). - public struct Content: Equatable, CodableVendorExtendable, Sendable { + public struct Content: HasConditionalWarnings, CodableVendorExtendable, Sendable { /// A schema describing the complete content of the request, response, /// parameter, or header. public var schema: JSONSchema? @@ -45,6 +45,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + /// The encoding of this `Content` (Media Type Object) if it is a map /// from property names to encoding information. /// @@ -84,6 +86,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema, a reference to a schema, or no @@ -106,6 +110,8 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } /// Create `Content` with a schema, a reference to a schema, or no @@ -127,6 +133,8 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } /// Create `Content` with a reference to a schema and optionally @@ -142,6 +150,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a single @@ -159,6 +169,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema, a reference to a schema, or no @@ -183,6 +195,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a reference to a schema and optionally @@ -198,6 +212,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -215,6 +231,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -231,6 +249,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -252,10 +272,52 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } } } +extension OpenAPI.Content: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.schema == rhs.schema + && lhs.itemSchema == rhs.itemSchema + && lhs.example == rhs.example + && lhs.examples == rhs.examples + && lhs.encoding == rhs.encoding + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + + +extension OpenAPI.Content { + fileprivate static func conditionalWarnings(itemSchema: JSONSchema?, prefixEncoding: [Encoding]?, itemEncoding: Encoding?) -> [(any Condition, OpenAPI.Warning)] { + let itemSchemaWarning: (any Condition, OpenAPI.Warning)? = + itemSchema.map { _ in + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object itemSchema property") + } + let prefixEncodingWarning : (any Condition, OpenAPI.Warning)? = + prefixEncoding.flatMap { prefixEncoding in + if prefixEncoding == [] { + nil + } else { + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object prefixEncoding property") + } + } + + let itemEncodingWarning : (any Condition, OpenAPI.Warning)? = + itemEncoding.map { _ in + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object itemEncoding property") + } + + return [ + itemSchemaWarning, + prefixEncodingWarning, + itemEncodingWarning + ].compactMap { $0 } + } +} + extension OpenAPI.Content { public typealias Map = OrderedDictionary } @@ -342,12 +404,17 @@ extension OpenAPI.Content: Decodable { schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) itemSchema = try container.decodeIfPresent(JSONSchema.self, forKey: .itemSchema) + var maybePrefixEncoding: [Encoding]? = nil + var maybeItemEncoding: Encoding? = nil if container.contains(.encoding) { encoding = .a(try container.decode(OrderedDictionary.self, forKey: .encoding)) } else if container.contains(.prefixEncoding) || container.contains(.itemEncoding) { let prefixEncoding = try container.decodeIfPresent([Encoding].self, forKey: .prefixEncoding) ?? [] let itemEncoding = try container.decodeIfPresent(Encoding.self, forKey: .itemEncoding) + maybePrefixEncoding = prefixEncoding + maybeItemEncoding = itemEncoding + encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) } else { encoding = nil @@ -363,6 +430,8 @@ extension OpenAPI.Content: Decodable { } vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: maybePrefixEncoding, itemEncoding: maybeItemEncoding) } } diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c6b92ae75..7a5a3d197 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -43,6 +43,7 @@ final class ContentTests: XCTestCase { // is the order in which the examples are given: XCTAssertEqual(withExamples.example?.value as? String, "world") XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + XCTAssertEqual(withExamples.conditionalWarnings.count, 0) let withExamples2 = OpenAPI.Content( itemSchema: .string, @@ -58,6 +59,7 @@ final class ContentTests: XCTestCase { // is the order in which the examples are given: XCTAssertEqual(withExamples2.example?.value as? String, "world") XCTAssertEqual(withExamples2.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + XCTAssertEqual(withExamples2.conditionalWarnings.count, 1) let t4 = OpenAPI.Content( schemaReference: .external(URL(string: "hello.json#/world")!), @@ -103,6 +105,7 @@ final class ContentTests: XCTestCase { XCTAssertNil(withPrefixEncoding.schema) XCTAssertEqual(withPrefixEncoding.itemSchema, .string) XCTAssertEqual(withPrefixEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: nil)) + XCTAssertEqual(withPrefixEncoding.conditionalWarnings.count, 2) let withItemEncoding = OpenAPI.Content( itemSchema: .string, @@ -112,6 +115,7 @@ final class ContentTests: XCTestCase { XCTAssertNil(withItemEncoding.schema) XCTAssertEqual(withItemEncoding.itemSchema, .string) XCTAssertEqual(withItemEncoding.encoding?.positionalValue, .init(itemEncoding: .init())) + XCTAssertEqual(withItemEncoding.conditionalWarnings.count, 2) let withPrefixAndItemEncoding = OpenAPI.Content( itemSchema: .string, @@ -124,6 +128,7 @@ final class ContentTests: XCTestCase { XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) XCTAssertEqual(withPrefixAndItemEncoding.prefixEncoding, [.init()]) XCTAssertEqual(withPrefixAndItemEncoding.itemEncoding, .init()) + XCTAssertEqual(withPrefixAndItemEncoding.conditionalWarnings.count, 3) XCTAssertEqual( OpenAPI.Content( From 566988e194c2b9e26294dbd8e45fceb941446e7b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 29 Nov 2025 20:05:12 -0600 Subject: [PATCH 42/73] stop running jammy because it has a bug in async execution --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a41f29e2..7fb41677c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: - swift:6.2-jammy - swift:6.2-noble - swiftlang/swift:nightly-focal - - swiftlang/swift:nightly-jammy +# - swiftlang/swift:nightly-jammy container: ${{ matrix.image }} steps: - name: Checkout code From 6861de8fe963b46cde2e51abf82f7f63dc8d3e46 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 9 Dec 2025 10:20:07 -0600 Subject: [PATCH 43/73] move the OAuthFlows type out of Core now that it must be different for OAS 3.0.x and OAS 3.2.x --- Sources/OpenAPIKit/Security/OAuthFlows.swift | 67 ++++++++++++++++++ Sources/OpenAPIKit/_CoreReExport.swift | 12 +++- .../OpenAPIKit30/Security/OAuthFlows.swift | 68 +++++++++++++++++++ Sources/OpenAPIKit30/_CoreReExport.swift | 12 +++- Sources/OpenAPIKitCompat/Compat30To31.swift | 13 +++- ...AuthFlows.swift => SharedOAuthFlows.swift} | 56 +-------------- 6 files changed, 171 insertions(+), 57 deletions(-) create mode 100644 Sources/OpenAPIKit/Security/OAuthFlows.swift create mode 100644 Sources/OpenAPIKit30/Security/OAuthFlows.swift rename Sources/OpenAPIKitCore/Shared/{OAuthFlows.swift => SharedOAuthFlows.swift} (76%) diff --git a/Sources/OpenAPIKit/Security/OAuthFlows.swift b/Sources/OpenAPIKit/Security/OAuthFlows.swift new file mode 100644 index 000000000..d0a46c910 --- /dev/null +++ b/Sources/OpenAPIKit/Security/OAuthFlows.swift @@ -0,0 +1,67 @@ +// +// OAuthFlows.swift +// +// +// Created by Mathew Polzin on 1/23/20. +// + +import OpenAPIKitCore +import Foundation + +extension OpenAPI { + /// OpenAPI Spec "Oauth Flows Object" + /// + /// See [OpenAPI Oauth Flows Object](https://spec.openapis.org/oas/v3.0.4.html#oauth-flows-object). + public struct OAuthFlows: Equatable, Sendable { + public let implicit: Implicit? + public let password: Password? + public let clientCredentials: ClientCredentials? + public let authorizationCode: AuthorizationCode? + + public init( + implicit: Implicit? = nil, + password: Password? = nil, + clientCredentials: ClientCredentials? = nil, + authorizationCode: AuthorizationCode? = nil + ) { + self.implicit = implicit + self.password = password + self.clientCredentials = clientCredentials + self.authorizationCode = authorizationCode + } + } +} + +// MARK: - Codable +extension OpenAPI.OAuthFlows { + private enum CodingKeys: String, CodingKey { + case implicit + case password + case clientCredentials + case authorizationCode + } +} + +extension OpenAPI.OAuthFlows: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(implicit, forKey: .implicit) + try container.encodeIfPresent(password, forKey: .password) + try container.encodeIfPresent(clientCredentials, forKey: .clientCredentials) + try container.encodeIfPresent(authorizationCode, forKey: .authorizationCode) + } +} + +extension OpenAPI.OAuthFlows: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + implicit = try container.decodeIfPresent(OpenAPI.OAuthFlows.Implicit.self, forKey: .implicit) + password = try container.decodeIfPresent(OpenAPI.OAuthFlows.Password.self, forKey: .password) + clientCredentials = try container.decodeIfPresent(OpenAPI.OAuthFlows.ClientCredentials.self, forKey: .clientCredentials) + authorizationCode = try container.decodeIfPresent(OpenAPI.OAuthFlows.AuthorizationCode.self, forKey: .authorizationCode) + } +} + +extension OpenAPI.OAuthFlows: Validatable {} diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index 17e2b37ea..e4d850978 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -23,10 +23,20 @@ public extension OpenAPI { typealias Path = OpenAPIKitCore.Shared.Path typealias ComponentKey = OpenAPIKitCore.Shared.ComponentKey typealias Discriminator = OpenAPIKitCore.Shared.Discriminator - typealias OAuthFlows = OpenAPIKitCore.Shared.OAuthFlows typealias CallbackURL = OpenAPIKitCore.Shared.CallbackURL } +public extension OpenAPI.OAuthFlows { + typealias Scope = Shared.OAuthFlows.Scope + typealias ScopeDescription = Shared.OAuthFlows.ScopeDescription + + typealias CommonFields = Shared.OAuthFlows.CommonFields + typealias Implicit = Shared.OAuthFlows.Implicit + typealias Password = Shared.OAuthFlows.Password + typealias ClientCredentials = Shared.OAuthFlows.ClientCredentials + typealias AuthorizationCode = Shared.OAuthFlows.AuthorizationCode +} + public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } diff --git a/Sources/OpenAPIKit30/Security/OAuthFlows.swift b/Sources/OpenAPIKit30/Security/OAuthFlows.swift new file mode 100644 index 000000000..baa25f7d4 --- /dev/null +++ b/Sources/OpenAPIKit30/Security/OAuthFlows.swift @@ -0,0 +1,68 @@ +// +// OAuthFlows.swift +// +// +// Created by Mathew Polzin on 1/23/20. +// + +import OpenAPIKitCore +import Foundation + +extension OpenAPI { + /// OpenAPI Spec "Oauth Flows Object" + /// + /// See [OpenAPI Oauth Flows Object](https://spec.openapis.org/oas/v3.0.4.html#oauth-flows-object). + public struct OAuthFlows: Equatable, Sendable { + public let implicit: Implicit? + public let password: Password? + public let clientCredentials: ClientCredentials? + public let authorizationCode: AuthorizationCode? + + public init( + implicit: Implicit? = nil, + password: Password? = nil, + clientCredentials: ClientCredentials? = nil, + authorizationCode: AuthorizationCode? = nil + ) { + self.implicit = implicit + self.password = password + self.clientCredentials = clientCredentials + self.authorizationCode = authorizationCode + } + } +} + +// MARK: - Codable +extension OpenAPI.OAuthFlows { + private enum CodingKeys: String, CodingKey { + case implicit + case password + case clientCredentials + case authorizationCode + } +} + +extension OpenAPI.OAuthFlows: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(implicit, forKey: .implicit) + try container.encodeIfPresent(password, forKey: .password) + try container.encodeIfPresent(clientCredentials, forKey: .clientCredentials) + try container.encodeIfPresent(authorizationCode, forKey: .authorizationCode) + } +} + +extension OpenAPI.OAuthFlows: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + implicit = try container.decodeIfPresent(OpenAPI.OAuthFlows.Implicit.self, forKey: .implicit) + password = try container.decodeIfPresent(OpenAPI.OAuthFlows.Password.self, forKey: .password) + clientCredentials = try container.decodeIfPresent(OpenAPI.OAuthFlows.ClientCredentials.self, forKey: .clientCredentials) + authorizationCode = try container.decodeIfPresent(OpenAPI.OAuthFlows.AuthorizationCode.self, forKey: .authorizationCode) + } +} + + +extension OpenAPI.OAuthFlows: Validatable {} diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index 17e2b37ea..e4d850978 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -23,10 +23,20 @@ public extension OpenAPI { typealias Path = OpenAPIKitCore.Shared.Path typealias ComponentKey = OpenAPIKitCore.Shared.ComponentKey typealias Discriminator = OpenAPIKitCore.Shared.Discriminator - typealias OAuthFlows = OpenAPIKitCore.Shared.OAuthFlows typealias CallbackURL = OpenAPIKitCore.Shared.CallbackURL } +public extension OpenAPI.OAuthFlows { + typealias Scope = Shared.OAuthFlows.Scope + typealias ScopeDescription = Shared.OAuthFlows.ScopeDescription + + typealias CommonFields = Shared.OAuthFlows.CommonFields + typealias Implicit = Shared.OAuthFlows.Implicit + typealias Password = Shared.OAuthFlows.Password + typealias ClientCredentials = Shared.OAuthFlows.ClientCredentials + typealias AuthorizationCode = Shared.OAuthFlows.AuthorizationCode +} + public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index df5b3193b..d6e0964e8 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -466,6 +466,17 @@ extension OpenAPIKit30.OpenAPI.ExternalDocumentation: To31 { } } +extension OpenAPIKit30.OpenAPI.OAuthFlows: To31 { + fileprivate func to31() -> OpenAPIKit.OpenAPI.OAuthFlows { + OpenAPIKit.OpenAPI.OAuthFlows( + implicit: implicit, + password: password, + clientCredentials: clientCredentials, + authorizationCode: authorizationCode + ) + } +} + extension OpenAPIKit30.OpenAPI.SecurityScheme.SecurityType: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.SecurityScheme.SecurityType { switch self { @@ -474,7 +485,7 @@ extension OpenAPIKit30.OpenAPI.SecurityScheme.SecurityType: To31 { case .http(scheme: let scheme, bearerFormat: let bearerFormat): return .http(scheme: scheme, bearerFormat: bearerFormat) case .oauth2(flows: let flows): - return .oauth2(flows: flows) + return .oauth2(flows: flows.to31()) case .openIdConnect(openIdConnectUrl: let openIdConnectUrl): return .openIdConnect(openIdConnectUrl: openIdConnectUrl) } diff --git a/Sources/OpenAPIKitCore/Shared/OAuthFlows.swift b/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift similarity index 76% rename from Sources/OpenAPIKitCore/Shared/OAuthFlows.swift rename to Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift index 7eaab4b05..31ad4822d 100644 --- a/Sources/OpenAPIKitCore/Shared/OAuthFlows.swift +++ b/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift @@ -1,5 +1,5 @@ // -// OAuthFlows.swift +// SharedOAuthFlows.swift // // // Created by Mathew Polzin on 1/23/20. @@ -8,27 +8,7 @@ import Foundation extension Shared { - /// OpenAPI Spec "Oauth Flows Object" - /// - /// See [OpenAPI Oauth Flows Object](https://spec.openapis.org/oas/v3.0.4.html#oauth-flows-object). - public struct OAuthFlows: Equatable, Sendable { - public let implicit: Implicit? - public let password: Password? - public let clientCredentials: ClientCredentials? - public let authorizationCode: AuthorizationCode? - - public init( - implicit: Implicit? = nil, - password: Password? = nil, - clientCredentials: ClientCredentials? = nil, - authorizationCode: AuthorizationCode? = nil - ) { - self.implicit = implicit - self.password = password - self.clientCredentials = clientCredentials - self.authorizationCode = authorizationCode - } - } + public enum OAuthFlows {} } extension Shared.OAuthFlows { @@ -104,15 +84,6 @@ extension Shared.OAuthFlows { } // MARK: - Codable -extension Shared.OAuthFlows { - private enum CodingKeys: String, CodingKey { - case implicit - case password - case clientCredentials - case authorizationCode - } -} - extension Shared.OAuthFlows.CommonFields { private enum CodingKeys: String, CodingKey { case refreshUrl @@ -145,28 +116,6 @@ extension Shared.OAuthFlows.AuthorizationCode { } } -extension Shared.OAuthFlows: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encodeIfPresent(implicit, forKey: .implicit) - try container.encodeIfPresent(password, forKey: .password) - try container.encodeIfPresent(clientCredentials, forKey: .clientCredentials) - try container.encodeIfPresent(authorizationCode, forKey: .authorizationCode) - } -} - -extension Shared.OAuthFlows: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - implicit = try container.decodeIfPresent(Shared.OAuthFlows.Implicit.self, forKey: .implicit) - password = try container.decodeIfPresent(Shared.OAuthFlows.Password.self, forKey: .password) - clientCredentials = try container.decodeIfPresent(Shared.OAuthFlows.ClientCredentials.self, forKey: .clientCredentials) - authorizationCode = try container.decodeIfPresent(Shared.OAuthFlows.AuthorizationCode.self, forKey: .authorizationCode) - } -} - extension Shared.OAuthFlows.CommonFields: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -267,7 +216,6 @@ extension Shared.OAuthFlows.AuthorizationCode: Decodable { } } -extension Shared.OAuthFlows: Validatable {} extension Shared.OAuthFlows.Implicit: Validatable {} extension Shared.OAuthFlows.Password: Validatable {} extension Shared.OAuthFlows.ClientCredentials: Validatable {} From 95e24899823fb01a17683fc7406c09a20f4d302b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 9 Dec 2025 10:33:29 -0600 Subject: [PATCH 44/73] add new DeviceAuthorization OAuth flow --- Sources/OpenAPIKit/Security/OAuthFlows.swift | 62 ++++++++++++++++++- .../Shared/SharedOAuthFlows.swift | 5 ++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Security/OAuthFlows.swift b/Sources/OpenAPIKit/Security/OAuthFlows.swift index d0a46c910..312cdc030 100644 --- a/Sources/OpenAPIKit/Security/OAuthFlows.swift +++ b/Sources/OpenAPIKit/Security/OAuthFlows.swift @@ -17,17 +17,39 @@ extension OpenAPI { public let password: Password? public let clientCredentials: ClientCredentials? public let authorizationCode: AuthorizationCode? + public let deviceAuthorization: DeviceAuthorization? public init( implicit: Implicit? = nil, password: Password? = nil, clientCredentials: ClientCredentials? = nil, - authorizationCode: AuthorizationCode? = nil + authorizationCode: AuthorizationCode? = nil, + deviceAuthorization: DeviceAuthorization? = nil ) { self.implicit = implicit self.password = password self.clientCredentials = clientCredentials self.authorizationCode = authorizationCode + self.deviceAuthorization = deviceAuthorization + } + } +} + +extension OpenAPI.OAuthFlows { + @dynamicMemberLookup + public struct DeviceAuthorization: Equatable, Sendable { + private let common: CommonFields + public let deviceAuthorizationUrl: URL + public let tokenUrl: URL + + public init(deviceAuthorizationUrl: URL, tokenUrl: URL, refreshUrl: URL? = nil, scopes: OrderedDictionary) { + self.deviceAuthorizationUrl = deviceAuthorizationUrl + self.tokenUrl = tokenUrl + common = .init(refreshUrl: refreshUrl, scopes: scopes) + } + + public subscript(dynamicMember path: KeyPath) -> T { + return common[keyPath: path] } } } @@ -39,6 +61,14 @@ extension OpenAPI.OAuthFlows { case password case clientCredentials case authorizationCode + case deviceAuthorization + } +} + +extension OpenAPI.OAuthFlows.DeviceAuthorization { + private enum CodingKeys: String, CodingKey { + case deviceAuthorizationUrl + case tokenUrl } } @@ -50,6 +80,7 @@ extension OpenAPI.OAuthFlows: Encodable { try container.encodeIfPresent(password, forKey: .password) try container.encodeIfPresent(clientCredentials, forKey: .clientCredentials) try container.encodeIfPresent(authorizationCode, forKey: .authorizationCode) + try container.encodeIfPresent(deviceAuthorization, forKey: .deviceAuthorization) } } @@ -61,7 +92,36 @@ extension OpenAPI.OAuthFlows: Decodable { password = try container.decodeIfPresent(OpenAPI.OAuthFlows.Password.self, forKey: .password) clientCredentials = try container.decodeIfPresent(OpenAPI.OAuthFlows.ClientCredentials.self, forKey: .clientCredentials) authorizationCode = try container.decodeIfPresent(OpenAPI.OAuthFlows.AuthorizationCode.self, forKey: .authorizationCode) + deviceAuthorization = try container.decodeIfPresent(OpenAPI.OAuthFlows.DeviceAuthorization.self, forKey: .deviceAuthorization) + } +} + +extension OpenAPI.OAuthFlows.DeviceAuthorization: Encodable { + public func encode(to encoder: Encoder) throws { + try common.encode(to: encoder) + + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(tokenUrl.absoluteString, forKey: .tokenUrl) + try container.encode(deviceAuthorizationUrl.absoluteString, forKey: .deviceAuthorizationUrl) + } +} + +extension OpenAPI.OAuthFlows.DeviceAuthorization: Decodable { + public init(from decoder: Decoder) throws { + common = try OpenAPI.OAuthFlows.CommonFields(from: decoder) + + let container = try decoder.container(keyedBy: CodingKeys.self) + + tokenUrl = try container.decodeURLAsString(forKey: .tokenUrl) + deviceAuthorizationUrl = try container.decodeURLAsString(forKey: .deviceAuthorizationUrl) } } extension OpenAPI.OAuthFlows: Validatable {} +extension OpenAPI.OAuthFlows.DeviceAuthorization: Validatable {} +// The following conformances are found in Core +// extension Shared.OAuthFlows.Implicit: Validatable {} +// extension Shared.OAuthFlows.Password: Validatable {} +// extension Shared.OAuthFlows.ClientCredentials: Validatable {} +// extension Shared.OAuthFlows.AuthorizationCode: Validatable {} diff --git a/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift b/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift index 31ad4822d..ea29468c2 100644 --- a/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift +++ b/Sources/OpenAPIKitCore/Shared/SharedOAuthFlows.swift @@ -18,6 +18,11 @@ extension Shared.OAuthFlows { public struct CommonFields: Equatable, Sendable { public let refreshUrl: URL? public let scopes: OrderedDictionary + + public init(refreshUrl: URL?, scopes: OrderedDictionary) { + self.refreshUrl = refreshUrl + self.scopes = scopes + } } @dynamicMemberLookup From d66ac84fa39382d4a600f2ef61b0e23940652fa9 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 9 Dec 2025 10:38:24 -0600 Subject: [PATCH 45/73] update compat tests --- .../DocumentConversionTests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 0103e4eb5..c919fef1d 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1574,6 +1574,14 @@ fileprivate func assertEqualNewToOld(_ newComponents: OpenAPIKit.OpenAPI.Compone XCTAssertEqual(newComponents.vendorExtensions, oldComponents.vendorExtensions) } +fileprivate func assertEqualNewToOld(_ newOAuthFlows: OpenAPIKit.OpenAPI.OAuthFlows, _ oldOAuthFlows: OpenAPIKit30.OpenAPI.OAuthFlows) throws { + XCTAssertEqual(newOAuthFlows.implicit, oldOAuthFlows.implicit) + XCTAssertEqual(newOAuthFlows.password, oldOAuthFlows.password) + XCTAssertEqual(newOAuthFlows.clientCredentials, oldOAuthFlows.clientCredentials) + XCTAssertEqual(newOAuthFlows.authorizationCode, oldOAuthFlows.authorizationCode) + XCTAssertNil(newOAuthFlows.deviceAuthorization) +} + fileprivate func assertEqualNewToOld(_ newScheme: OpenAPIKit.OpenAPI.SecurityScheme, _ oldScheme: OpenAPIKit30.OpenAPI.SecurityScheme) throws { XCTAssertEqual(newScheme.description, oldScheme.description) XCTAssertEqual(newScheme.vendorExtensions, oldScheme.vendorExtensions) @@ -1586,7 +1594,7 @@ fileprivate func assertEqualNewToOld(_ newScheme: OpenAPIKit.OpenAPI.SecuritySch XCTAssertEqual(scheme, scheme2) XCTAssertEqual(format, format2) case (.oauth2(let flows), .oauth2(let flows2)): - XCTAssertEqual(flows, flows2) + try assertEqualNewToOld(flows, flows2) case (.openIdConnect(let url), .openIdConnect(let url2)): XCTAssertEqual(url, url2) case (.mutualTLS, _): From 857e1a5e6b2a6fa63450ae70b8faa84ac75b7014 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 08:21:46 -0600 Subject: [PATCH 46/73] cover the various oauth flows in tests --- .../Security/OauthFlowsTests.swift | 30 ++ .../Security/SecuritySchemeTests.swift | 256 +++++++++++++++++- 2 files changed, 284 insertions(+), 2 deletions(-) diff --git a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift index 182b51219..00ad4f6eb 100644 --- a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift +++ b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift @@ -129,6 +129,12 @@ extension OAuthFlowsTests { tokenUrl: testUrl, refreshUrl: testUrl, scopes: scopes + ), + deviceAuthorization: OpenAPI.OAuthFlows.DeviceAuthorization( + deviceAuthorizationUrl: testUrl, + tokenUrl: testUrl, + refreshUrl: testUrl, + scopes: scopes ) ) @@ -155,6 +161,15 @@ extension OAuthFlowsTests { }, "tokenUrl" : "http:\\/\\/google.com" }, + "deviceAuthorization" : { + "deviceAuthorizationUrl" : "http:\\/\\/google.com", + "refreshUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read things", + "write:test" : "write things" + }, + "tokenUrl" : "http:\\/\\/google.com" + }, "implicit" : { "authorizationUrl" : "http:\\/\\/google.com", "refreshUrl" : "http:\\/\\/google.com", @@ -212,6 +227,15 @@ extension OAuthFlowsTests { "write:test" : "write things" }, "tokenUrl" : "http://google.com" + }, + "deviceAuthorization" : { + "deviceAuthorizationUrl" : "http://google.com", + "refreshUrl" : "http://google.com", + "scopes" : { + "read:test" : "read things", + "write:test" : "write things" + }, + "tokenUrl" : "http://google.com" } } """.data(using: .utf8)! @@ -240,6 +264,12 @@ extension OAuthFlowsTests { XCTAssertEqual(oauthFlows.authorizationCode?.refreshUrl, testUrl) XCTAssertEqual(oauthFlows.authorizationCode?.scopes["read:test"], "read things") XCTAssertEqual(oauthFlows.authorizationCode?.scopes["write:test"], "write things") + + XCTAssertEqual(oauthFlows.deviceAuthorization?.deviceAuthorizationUrl, testUrl) + XCTAssertEqual(oauthFlows.deviceAuthorization?.tokenUrl, testUrl) + XCTAssertEqual(oauthFlows.deviceAuthorization?.refreshUrl, testUrl) + XCTAssertEqual(oauthFlows.deviceAuthorization?.scopes["read:test"], "read things") + XCTAssertEqual(oauthFlows.deviceAuthorization?.scopes["write:test"], "write things") } func test_implicitFlow_encode() throws { diff --git a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift index 7149c2820..567d4f0f2 100644 --- a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift +++ b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift @@ -218,7 +218,7 @@ extension SecuritySchemeTests { ) } - func test_oauth2_encode() throws { + func test_oauth2Implicit_encode() throws { let oauth = OpenAPI.SecurityScheme.oauth2( flows: .init( implicit: .init( @@ -248,7 +248,7 @@ extension SecuritySchemeTests { ) } - func test_oauth2_decode() throws { + func test_oauth2Implicit_decode() throws { let oauthData = """ { @@ -279,6 +279,258 @@ extension SecuritySchemeTests { ) } + func test_oauth2Password_encode() throws { + let oauth = OpenAPI.SecurityScheme.oauth2( + flows: .init( + password: .init( + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + + let encodedOAuth = try orderUnstableTestStringFromEncoding(of: oauth) + + assertJSONEquivalent( + encodedOAuth, + """ + { + "flows" : { + "password" : { + "scopes" : { + "read:test" : "read test" + }, + "tokenUrl" : "http:\\/\\/google.com" + } + }, + "type" : "oauth2" + } + """ + ) + } + + func test_oauth2Password_decode() throws { + let oauthData = + """ + { + "flows" : { + "password" : { + "scopes" : { + "read:test" : "read test" + }, + "tokenUrl" : "http:\\/\\/google.com" + } + }, + "type" : "oauth2" + } + """.data(using: .utf8)! + + let oauth = try orderUnstableDecode(OpenAPI.SecurityScheme.self, from: oauthData) + + XCTAssertEqual( + oauth, + OpenAPI.SecurityScheme.oauth2( + flows: .init( + password: .init( + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + ) + } + + func test_oauth2ClientCredentials_encode() throws { + let oauth = OpenAPI.SecurityScheme.oauth2( + flows: .init( + clientCredentials: .init( + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + + let encodedOAuth = try orderUnstableTestStringFromEncoding(of: oauth) + + assertJSONEquivalent( + encodedOAuth, + """ + { + "flows" : { + "clientCredentials" : { + "scopes" : { + "read:test" : "read test" + }, + "tokenUrl" : "http:\\/\\/google.com" + } + }, + "type" : "oauth2" + } + """ + ) + } + + func test_oauth2ClientCredentials_decode() throws { + let oauthData = + """ + { + "flows" : { + "clientCredentials" : { + "tokenUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read test" + } + } + }, + "type" : "oauth2" + } + """.data(using: .utf8)! + + let oauth = try orderUnstableDecode(OpenAPI.SecurityScheme.self, from: oauthData) + + XCTAssertEqual( + oauth, + OpenAPI.SecurityScheme.oauth2( + flows: .init( + clientCredentials: .init( + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + ) + } + + func test_oauth2AuthorizationCode_encode() throws { + let oauth = OpenAPI.SecurityScheme.oauth2( + flows: .init( + authorizationCode: .init( + authorizationUrl: URL(string: "http://google.com")!, + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + + let encodedOAuth = try orderUnstableTestStringFromEncoding(of: oauth) + + assertJSONEquivalent( + encodedOAuth, + """ + { + "flows" : { + "authorizationCode" : { + "authorizationUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read test" + }, + "tokenUrl" : "http:\\/\\/google.com" + } + }, + "type" : "oauth2" + } + """ + ) + } + + func test_oauth2AuthorizationCode_decode() throws { + let oauthData = + """ + { + "flows" : { + "authorizationCode" : { + "authorizationUrl" : "http:\\/\\/google.com", + "tokenUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read test" + } + } + }, + "type" : "oauth2" + } + """.data(using: .utf8)! + + let oauth = try orderUnstableDecode(OpenAPI.SecurityScheme.self, from: oauthData) + + XCTAssertEqual( + oauth, + OpenAPI.SecurityScheme.oauth2( + flows: .init( + authorizationCode: .init( + authorizationUrl: URL(string: "http://google.com")!, + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + ) + } + + func test_oauth2DeviceAuthorization_encode() throws { + let oauth = OpenAPI.SecurityScheme.oauth2( + flows: .init( + deviceAuthorization: .init( + deviceAuthorizationUrl: URL(string: "http://google.com")!, + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + + let encodedOAuth = try orderUnstableTestStringFromEncoding(of: oauth) + + assertJSONEquivalent( + encodedOAuth, + """ + { + "flows" : { + "deviceAuthorization" : { + "deviceAuthorizationUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read test" + }, + "tokenUrl" : "http:\\/\\/google.com" + } + }, + "type" : "oauth2" + } + """ + ) + } + + func test_oauth2DeviceAuthorization_decode() throws { + let oauthData = + """ + { + "flows" : { + "deviceAuthorization" : { + "deviceAuthorizationUrl" : "http:\\/\\/google.com", + "tokenUrl" : "http:\\/\\/google.com", + "scopes" : { + "read:test" : "read test" + } + } + }, + "type" : "oauth2" + } + """.data(using: .utf8)! + + let oauth = try orderUnstableDecode(OpenAPI.SecurityScheme.self, from: oauthData) + + XCTAssertEqual( + oauth, + OpenAPI.SecurityScheme.oauth2( + flows: .init( + deviceAuthorization: .init( + deviceAuthorizationUrl: URL(string: "http://google.com")!, + tokenUrl: URL(string: "http://google.com")!, + scopes: ["read:test": "read test"] + ) + ) + ) + ) + } + func test_openIdConnect_encode() throws { let openIdConnect = OpenAPI.SecurityScheme.openIdConnect(url: URL(string: "http://google.com")!) From e449abbcd1c9a017ddfa7ae85188b959f02670bd Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 08:45:18 -0600 Subject: [PATCH 47/73] add new security scheme fields --- .../OpenAPIKit/Security/SecurityScheme.swift | 58 ++++++++++++++----- Sources/OpenAPIKitCompat/Compat30To31.swift | 2 +- .../DocumentConversionTests.swift | 3 +- Tests/OpenAPIKitTests/EaseOfUseTests.swift | 7 ++- .../Security/SecuritySchemeTests.swift | 6 +- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/Sources/OpenAPIKit/Security/SecurityScheme.swift b/Sources/OpenAPIKit/Security/SecurityScheme.swift index eec43ef35..50e900134 100644 --- a/Sources/OpenAPIKit/Security/SecurityScheme.swift +++ b/Sources/OpenAPIKit/Security/SecurityScheme.swift @@ -15,6 +15,10 @@ extension OpenAPI { public struct SecurityScheme: Equatable, CodableVendorExtendable, Sendable { public var type: SecurityType public var description: String? + /// Indication of if the security scheme is deprecated. Defaults to + /// `false` and OpenAPIKit only encodes this property if it is set to + /// `true`. + public var deprecated: Bool /// Dictionary of vendor extensions. /// @@ -26,37 +30,39 @@ extension OpenAPI { public init( type: SecurityType, description: String? = nil, - vendorExtensions: [String: AnyCodable] = [:] + vendorExtensions: [String: AnyCodable] = [:], + deprecated: Bool = false ) { self.type = type self.description = description self.vendorExtensions = vendorExtensions + self.deprecated = deprecated } - public static func apiKey(name: String, location: Location, description: String? = nil) -> SecurityScheme { - return .init(type: .apiKey(name: name, location: location), description: description) + public static func apiKey(name: String, location: Location, description: String? = nil, deprecated: Bool = false) -> SecurityScheme { + return .init(type: .apiKey(name: name, location: location), description: description, deprecated: deprecated) } - public static func http(scheme: String, bearerFormat: String? = nil, description: String? = nil) -> SecurityScheme { - return .init(type: .http(scheme: scheme, bearerFormat: bearerFormat), description: description) + public static func http(scheme: String, bearerFormat: String? = nil, description: String? = nil, deprecated: Bool = false) -> SecurityScheme { + return .init(type: .http(scheme: scheme, bearerFormat: bearerFormat), description: description, deprecated: deprecated) } - public static func oauth2(flows: OAuthFlows, description: String? = nil) -> SecurityScheme { - return .init(type: .oauth2(flows: flows), description: description) + public static func oauth2(flows: OAuthFlows, metadataUrl: URL? = nil, description: String? = nil, deprecated: Bool = false) -> SecurityScheme { + return .init(type: .oauth2(flows: flows, metadataUrl: metadataUrl), description: description, deprecated: deprecated) } - public static func openIdConnect(url: URL, description: String? = nil) -> SecurityScheme { - return .init(type: .openIdConnect(openIdConnectUrl: url), description: description) + public static func openIdConnect(url: URL, description: String? = nil, deprecated: Bool = false) -> SecurityScheme { + return .init(type: .openIdConnect(openIdConnectUrl: url), description: description, deprecated: deprecated) } - public static func mutualTLS(description: String? = nil) -> SecurityScheme { - return .init(type: .mutualTLS, description: description) + public static func mutualTLS(description: String? = nil, deprecated: Bool = false) -> SecurityScheme { + return .init(type: .mutualTLS, description: description, deprecated: deprecated) } public enum SecurityType: Equatable, Sendable { case apiKey(name: String, location: Location) case http(scheme: String, bearerFormat: String?) - case oauth2(flows: OAuthFlows) + case oauth2(flows: OAuthFlows, metadataUrl: URL?) case openIdConnect(openIdConnectUrl: URL) case mutualTLS } @@ -106,6 +112,10 @@ extension OpenAPI.SecurityScheme: Encodable { try container.encodeIfPresent(description, forKey: .description) + if deprecated { + try container.encode(deprecated, forKey: .deprecated) + } + switch type { case .apiKey(name: let name, location: let location): try container.encode(SecurityType.Name.apiKey, forKey: .type) @@ -118,9 +128,10 @@ extension OpenAPI.SecurityScheme: Encodable { case .openIdConnect(openIdConnectUrl: let url): try container.encode(SecurityType.Name.openIdConnect, forKey: .type) try container.encode(url.absoluteString, forKey: .openIdConnectUrl) - case .oauth2(flows: let flows): + case .oauth2(flows: let flows, metadataUrl: let url): try container.encode(SecurityType.Name.oauth2, forKey: .type) try container.encode(flows, forKey: .flows) + try container.encodeIfPresent(url?.absoluteString, forKey: .oauth2MetadataUrl) case .mutualTLS: try container.encode(SecurityType.Name.mutualTLS, forKey: .type) } @@ -137,6 +148,8 @@ extension OpenAPI.SecurityScheme: Decodable { description = try container.decodeIfPresent(String.self, forKey: .description) + deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false + let typeName = try container.decode(SecurityType.Name.self, forKey: .type) switch typeName { @@ -154,7 +167,8 @@ extension OpenAPI.SecurityScheme: Decodable { ) case .oauth2: type = .oauth2( - flows: try container.decode(OpenAPI.OAuthFlows.self, forKey: .flows) + flows: try container.decode(OpenAPI.OAuthFlows.self, forKey: .flows), + metadataUrl: try container.decodeURLAsStringIfPresent(forKey: .oauth2MetadataUrl) ) case .openIdConnect: type = .openIdConnect( @@ -186,24 +200,28 @@ extension OpenAPI.SecurityScheme { internal enum CodingKeys: ExtendableCodingKey { case type case description + case deprecated case name case location case scheme case bearerFormat case flows case openIdConnectUrl + case oauth2MetadataUrl case extended(String) static var allBuiltinKeys: [CodingKeys] { return [ .type, .description, + .deprecated, .name, .location, .scheme, .bearerFormat, .flows, - .openIdConnectUrl + .openIdConnectUrl, + .oauth2MetadataUrl ] } @@ -217,6 +235,8 @@ extension OpenAPI.SecurityScheme { self = .type case "description": self = .description + case "deprecated": + self = .deprecated case "name": self = .name case "in": @@ -229,6 +249,8 @@ extension OpenAPI.SecurityScheme { self = .flows case "openIdConnectUrl": self = .openIdConnectUrl + case "oauth2MetadataUrl": + self = .oauth2MetadataUrl default: self = .extendedKey(for: stringValue) } @@ -240,6 +262,8 @@ extension OpenAPI.SecurityScheme { return "type" case .description: return "description" + case .deprecated: + return "deprecated" case .name: return "name" case .location: @@ -252,6 +276,8 @@ extension OpenAPI.SecurityScheme { return "flows" case .openIdConnectUrl: return "openIdConnectUrl" + case .oauth2MetadataUrl: + return "oauth2MetadataUrl" case .extended(let key): return key } @@ -281,3 +307,5 @@ extension OpenAPI.SecurityScheme: ExternallyDereferenceable { return (self, .init(), []) } } + +extension OpenAPI.SecurityScheme: Validatable {} diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index d6e0964e8..3746f3e16 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -485,7 +485,7 @@ extension OpenAPIKit30.OpenAPI.SecurityScheme.SecurityType: To31 { case .http(scheme: let scheme, bearerFormat: let bearerFormat): return .http(scheme: scheme, bearerFormat: bearerFormat) case .oauth2(flows: let flows): - return .oauth2(flows: flows.to31()) + return .oauth2(flows: flows.to31(), metadataUrl: nil) case .openIdConnect(openIdConnectUrl: let openIdConnectUrl): return .openIdConnect(openIdConnectUrl: openIdConnectUrl) } diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index c919fef1d..4b0b5653d 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1593,7 +1593,8 @@ fileprivate func assertEqualNewToOld(_ newScheme: OpenAPIKit.OpenAPI.SecuritySch case (.http(let scheme, let format), .http(let scheme2, let format2)): XCTAssertEqual(scheme, scheme2) XCTAssertEqual(format, format2) - case (.oauth2(let flows), .oauth2(let flows2)): + case (.oauth2(let flows, let metadataUrl), .oauth2(let flows2)): + XCTAssertNil(metadataUrl) try assertEqualNewToOld(flows, flows2) case (.openIdConnect(let url), .openIdConnect(let url2)): XCTAssertEqual(url, url2) diff --git a/Tests/OpenAPIKitTests/EaseOfUseTests.swift b/Tests/OpenAPIKitTests/EaseOfUseTests.swift index b6e59547b..917d06544 100644 --- a/Tests/OpenAPIKitTests/EaseOfUseTests.swift +++ b/Tests/OpenAPIKitTests/EaseOfUseTests.swift @@ -359,7 +359,8 @@ final class DeclarativeEaseOfUseTests: XCTestCase { "read:widgets" : "read those widgets" ] ) - ) + ), + metadataUrl: URL(string: "https://google.com")! ), description: "OAuth Flows" ) @@ -367,8 +368,8 @@ final class DeclarativeEaseOfUseTests: XCTestCase { let securityRequirements: [OpenAPI.SecurityRequirement] = [ [ - .component( named: "basic_auth"): [], - .component( named: "oauth_flow"): ["read:widgets"] + .component(named: "basic_auth"): [], + .component(named: "oauth_flow"): ["read:widgets"] ] ] diff --git a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift index 567d4f0f2..f9e85bc17 100644 --- a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift +++ b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift @@ -24,8 +24,8 @@ final class SecuritySchemeTests: XCTestCase { ) XCTAssertEqual( - OpenAPI.SecurityScheme(type: .oauth2(flows: .init()), description: "description"), - OpenAPI.SecurityScheme.oauth2(flows: .init(), description: "description") + OpenAPI.SecurityScheme(type: .oauth2(flows: .init(), metadataUrl: URL(string: "https://google.com")!), description: "description"), + OpenAPI.SecurityScheme.oauth2(flows: .init(), metadataUrl: URL(string: "https://google.com")!, description: "description") ) XCTAssertEqual( @@ -60,7 +60,7 @@ final class SecuritySchemeTests: XCTestCase { ) XCTAssertEqual( - OpenAPI.SecurityScheme(type: .oauth2(flows: .init()), description: "description").type.name, + OpenAPI.SecurityScheme(type: .oauth2(flows: .init(), metadataUrl: nil), description: "description").type.name, .oauth2 ) From 74f09494f832913ccb2d82922b89c4acdb1b695a Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 09:10:11 -0600 Subject: [PATCH 48/73] test that SecurityScheme is indeed Validatable --- .../Security/SecuritySchemeTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift index f9e85bc17..316d9cbfd 100644 --- a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift +++ b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift @@ -673,3 +673,34 @@ extension SecuritySchemeTests { ) } } + +// MARK: Validatable +extension SecuritySchemeTests { + func test_supportsValidations() throws { + let scheme = OpenAPI.SecurityScheme.mutualTLS(description: "hi", deprecated: true) + + let components = OpenAPI.Components( + securitySchemes: [ + "schemeOne": .securityScheme(scheme) + ] + ) + + let document = OpenAPI.Document( + info: OpenAPI.Document.Info(title: "hi", version: "1.0.0"), + servers: [], + paths: [:], + components: components + ) + + let validation: Validation = .init( + description: "No security schemes are deprecated", + check: \.deprecated != true + ) + + let validator = Validator.blank.validating(validation) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + XCTAssertEqual(OpenAPI.Error(from: error).localizedDescription, "Failed to satisfy: No security schemes are deprecated at path: .components.securitySchemes.schemeOne") + } + } +} From 8a5971625a9278f9ed25dd0e34f361ac9d94088b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 09:25:37 -0600 Subject: [PATCH 49/73] Add conditional warning for device authorization flow --- Sources/OpenAPIKit/Security/OAuthFlows.swift | 31 ++++++++++++++++++- .../OpenAPIKit/Security/SecurityScheme.swift | 9 ++++++ .../Security/OauthFlowsTests.swift | 28 +++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Security/OAuthFlows.swift b/Sources/OpenAPIKit/Security/OAuthFlows.swift index 312cdc030..dfdead040 100644 --- a/Sources/OpenAPIKit/Security/OAuthFlows.swift +++ b/Sources/OpenAPIKit/Security/OAuthFlows.swift @@ -12,13 +12,15 @@ extension OpenAPI { /// OpenAPI Spec "Oauth Flows Object" /// /// See [OpenAPI Oauth Flows Object](https://spec.openapis.org/oas/v3.0.4.html#oauth-flows-object). - public struct OAuthFlows: Equatable, Sendable { + public struct OAuthFlows: HasConditionalWarnings, Sendable { public let implicit: Implicit? public let password: Password? public let clientCredentials: ClientCredentials? public let authorizationCode: AuthorizationCode? public let deviceAuthorization: DeviceAuthorization? + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( implicit: Implicit? = nil, password: Password? = nil, @@ -31,10 +33,33 @@ extension OpenAPI { self.clientCredentials = clientCredentials self.authorizationCode = authorizationCode self.deviceAuthorization = deviceAuthorization + + self.conditionalWarnings = [ + nonNilVersionWarning(fieldName: "deviceAuthorization", value: deviceAuthorization, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +extension OpenAPI.OAuthFlows: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.implicit == rhs.implicit + && lhs.password == rhs.password + && lhs.clientCredentials == rhs.clientCredentials + && lhs.authorizationCode == rhs.authorizationCode + && lhs.deviceAuthorization == rhs.deviceAuthorization + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The OAuthFlows \(fieldName) field" + ) + } +} + extension OpenAPI.OAuthFlows { @dynamicMemberLookup public struct DeviceAuthorization: Equatable, Sendable { @@ -93,6 +118,10 @@ extension OpenAPI.OAuthFlows: Decodable { clientCredentials = try container.decodeIfPresent(OpenAPI.OAuthFlows.ClientCredentials.self, forKey: .clientCredentials) authorizationCode = try container.decodeIfPresent(OpenAPI.OAuthFlows.AuthorizationCode.self, forKey: .authorizationCode) deviceAuthorization = try container.decodeIfPresent(OpenAPI.OAuthFlows.DeviceAuthorization.self, forKey: .deviceAuthorization) + + self.conditionalWarnings = [ + nonNilVersionWarning(fieldName: "deviceAuthorization", value: deviceAuthorization, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } diff --git a/Sources/OpenAPIKit/Security/SecurityScheme.swift b/Sources/OpenAPIKit/Security/SecurityScheme.swift index 50e900134..fe3fa0f58 100644 --- a/Sources/OpenAPIKit/Security/SecurityScheme.swift +++ b/Sources/OpenAPIKit/Security/SecurityScheme.swift @@ -69,6 +69,15 @@ extension OpenAPI { } } +fileprivate func notFalseVersionWarning(fieldName: String, value: Bool, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + guard value else { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Security Scheme \(fieldName) field" + ) +} + extension OpenAPI.SecurityScheme.SecurityType { public enum Name: String, Codable { case apiKey diff --git a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift index 00ad4f6eb..69c1d4b6f 100644 --- a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift +++ b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift @@ -60,6 +60,18 @@ final class OAuthFlowsTests: XCTestCase { XCTAssertEqual(authorizationCodeFlow.refreshUrl, testUrl) XCTAssertEqual(authorizationCodeFlow.scopes, scopes) + let deviceAuthorizationFlow = OpenAPI.OAuthFlows.DeviceAuthorization( + deviceAuthorizationUrl: testUrl, + tokenUrl: testUrl, + refreshUrl: testUrl, + scopes: scopes + ) + + XCTAssertEqual(deviceAuthorizationFlow.deviceAuthorizationUrl, testUrl) + XCTAssertEqual(deviceAuthorizationFlow.tokenUrl, testUrl) + XCTAssertEqual(deviceAuthorizationFlow.refreshUrl, testUrl) + XCTAssertEqual(deviceAuthorizationFlow.scopes, scopes) + let flows = OpenAPI.OAuthFlows( implicit: implicitFlow, password: passwordFlow, @@ -71,6 +83,22 @@ final class OAuthFlowsTests: XCTestCase { XCTAssertEqual(flows.password, passwordFlow) XCTAssertEqual(flows.clientCredentials, clientCredentialsFlow) XCTAssertEqual(flows.authorizationCode, authorizationCodeFlow) + XCTAssertEqual(flows.conditionalWarnings.count, 0) + + let flows2 = OpenAPI.OAuthFlows( + implicit: implicitFlow, + password: passwordFlow, + clientCredentials: clientCredentialsFlow, + authorizationCode: authorizationCodeFlow, + authorizationCode: deviceAuthorizationFlow + ) + + XCTAssertEqual(flows2.implicit, implicitFlow) + XCTAssertEqual(flows2.password, passwordFlow) + XCTAssertEqual(flows2.clientCredentials, clientCredentialsFlow) + XCTAssertEqual(flows2.authorizationCode, authorizationCodeFlow) + XCTAssertEqual(flows2.deviceAuthorization, deviceAuthorizationFlow) + XCTAssertEqual(flows2.conditionalWarnings.count, 1) } } From 5f279d8f215a7c9bf1ef67a061a3468115d4676e Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 09:58:58 -0600 Subject: [PATCH 50/73] add conditional warnings for new security scheme fields --- .../OpenAPIKit/Security/SecurityScheme.swift | 38 ++++++++++++++++++- .../Security/OauthFlowsTests.swift | 2 +- .../Security/SecuritySchemeTests.swift | 7 ++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKit/Security/SecurityScheme.swift b/Sources/OpenAPIKit/Security/SecurityScheme.swift index fe3fa0f58..b83a0d593 100644 --- a/Sources/OpenAPIKit/Security/SecurityScheme.swift +++ b/Sources/OpenAPIKit/Security/SecurityScheme.swift @@ -12,7 +12,7 @@ extension OpenAPI { /// OpenAPI Spec "Security Scheme Object" /// /// See [OpenAPI Security Scheme Object](https://spec.openapis.org/oas/v3.1.1.html#security-scheme-object). - public struct SecurityScheme: Equatable, CodableVendorExtendable, Sendable { + public struct SecurityScheme: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var type: SecurityType public var description: String? /// Indication of if the security scheme is deprecated. Defaults to @@ -26,6 +26,8 @@ extension OpenAPI { /// `[ "x-extensionKey": ]` /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] public init( type: SecurityType, @@ -37,6 +39,11 @@ extension OpenAPI { self.description = description self.vendorExtensions = vendorExtensions self.deprecated = deprecated + + self.conditionalWarnings = [ + nonNilVersionWarning(fieldName: "oauth2MetadataUrl", value: type.oauth2MetadataUrl, minimumVersion: .v3_2_0), + notFalseVersionWarning(fieldName: "deprecated", value: deprecated, minimumVersion: .v3_2_0) + ].compactMap { $0 } } public static func apiKey(name: String, location: Location, description: String? = nil, deprecated: Bool = false) -> SecurityScheme { @@ -69,6 +76,15 @@ extension OpenAPI { } } +extension OpenAPI.SecurityScheme: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + && lhs.description == rhs.description + && lhs.deprecated == rhs.deprecated + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + fileprivate func notFalseVersionWarning(fieldName: String, value: Bool, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { guard value else { return nil } @@ -78,6 +94,15 @@ fileprivate func notFalseVersionWarning(fieldName: String, value: Bool, minimumV ) } +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Security Scheme \(fieldName) field" + ) + } +} + extension OpenAPI.SecurityScheme.SecurityType { public enum Name: String, Codable { case apiKey @@ -101,6 +126,12 @@ extension OpenAPI.SecurityScheme.SecurityType { return .mutualTLS } } + + public var oauth2MetadataUrl: URL? { + guard case let .oauth2(_, metadataUrl: metadataUrl) = self else { return nil } + + return metadataUrl + } } // MARK: - Describable @@ -188,6 +219,11 @@ extension OpenAPI.SecurityScheme: Decodable { } vendorExtensions = try Self.extensions(from: decoder) + + self.conditionalWarnings = [ + nonNilVersionWarning(fieldName: "oauth2MetadataUrl", value: type.oauth2MetadataUrl, minimumVersion: .v3_2_0), + notFalseVersionWarning(fieldName: "deprecated", value: deprecated, minimumVersion: .v3_2_0) + ].compactMap { $0 } } internal static func decodeAPIKey(from container: KeyedDecodingContainer) throws -> (name: String, location: Location) { diff --git a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift index 69c1d4b6f..d02ba30b2 100644 --- a/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift +++ b/Tests/OpenAPIKitTests/Security/OauthFlowsTests.swift @@ -90,7 +90,7 @@ final class OAuthFlowsTests: XCTestCase { password: passwordFlow, clientCredentials: clientCredentialsFlow, authorizationCode: authorizationCodeFlow, - authorizationCode: deviceAuthorizationFlow + deviceAuthorization: deviceAuthorizationFlow ) XCTAssertEqual(flows2.implicit, implicitFlow) diff --git a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift index 316d9cbfd..3dfa3ecd3 100644 --- a/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift +++ b/Tests/OpenAPIKitTests/Security/SecuritySchemeTests.swift @@ -37,6 +37,12 @@ final class SecuritySchemeTests: XCTestCase { OpenAPI.SecurityScheme(type: .mutualTLS, description: "description"), OpenAPI.SecurityScheme.mutualTLS(description: "description") ) + + let noWarnings = OpenAPI.SecurityScheme(type: .oauth2(flows: .init(), metadataUrl: nil), deprecated: false) + XCTAssertEqual(noWarnings.conditionalWarnings.count, 0) + + let twoWarnings = OpenAPI.SecurityScheme(type: .oauth2(flows: .init(), metadataUrl: URL(string: "https://google.com")!), deprecated: true) + XCTAssertEqual(twoWarnings.conditionalWarnings.count, 2) } func test_locallyDereferenced() throws { @@ -686,6 +692,7 @@ extension SecuritySchemeTests { ) let document = OpenAPI.Document( + openAPIVersion: .v3_2_0, info: OpenAPI.Document.Info(title: "hi", version: "1.0.0"), servers: [], paths: [:], From 9e7589e2dda947f7cfbdc2a939aaf5de5a1aef6c Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 10 Dec 2025 09:59:15 -0600 Subject: [PATCH 51/73] bump default open api standard version for new documents to 3.2.0 --- Sources/OpenAPIKit/Document/Document.swift | 2 +- .../Document/DocumentTests.swift | 42 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index d2a591c85..ea5812886 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -149,7 +149,7 @@ extension OpenAPI { public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] public init( - openAPIVersion: Version = .v3_1_1, + openAPIVersion: Version = .v3_2_0, selfURI: URL? = nil, info: Info, servers: [Server], diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index afd10edcf..dcd31ca67 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -520,7 +520,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1" + "openapi" : "3.2.0" } """ ) @@ -534,7 +534,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { } @@ -684,7 +684,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "servers" : [ { "url" : "http:\\/\\/google.com" @@ -703,7 +703,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -746,7 +746,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1" + "openapi" : "3.2.0" } """ ) @@ -761,7 +761,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { } @@ -798,7 +798,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { "\\/test" : { "summary" : "hi" @@ -817,7 +817,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { "\\/test" : { "summary" : "hi" @@ -867,7 +867,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "security" : [ { "security" : [ @@ -897,7 +897,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -944,7 +944,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "tags" : [ { "name" : "hi" @@ -963,7 +963,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -1009,7 +1009,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1" + "openapi" : "3.2.0" } """ ) @@ -1026,7 +1026,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { } @@ -1068,7 +1068,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "x-specialFeature" : [ "hello", "world" @@ -1089,7 +1089,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -1129,7 +1129,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -1181,7 +1181,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "webhooks" : { "webhook-test" : { "delete" : { @@ -1252,7 +1252,7 @@ extension DocumentTests { "title": "API", "version": "1.0" }, - "openapi": "3.1.1", + "openapi": "3.2.0", "paths": { }, "webhooks": { @@ -1324,7 +1324,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "webhooks" : { "webhook-test" : { "delete" : { @@ -1373,7 +1373,7 @@ extension DocumentTests { "title": "API", "version": "1.0" }, - "openapi": "3.1.1", + "openapi": "3.2.0", "webhooks": { "webhook-test": { "delete": { From 977ed9ca5693d59fbb802cdb9a89af68118934da Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 11 Dec 2025 09:22:15 -0600 Subject: [PATCH 52/73] Add support for non internal non components references to security schemes --- Sources/OpenAPIKit/Document/Document.swift | 56 +++++-- .../SecuritySchemeErrorTests.swift | 25 ++-- .../Document/DocumentTests.swift | 139 +++++++++++++++++- 3 files changed, 193 insertions(+), 27 deletions(-) diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index d2a591c85..8954787a3 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -857,7 +857,11 @@ internal func encodeSecurity(requirements security: [Open var securityContainer = container.nestedUnkeyedContainer(forKey: key) for securityRequirement in security { let securityKeysAndValues = securityRequirement - .compactMap { keyValue in keyValue.key.name.map { ($0, keyValue.value) } } + .compactMap { (key, value) in + guard case .internal = key else { return (key.absoluteString, value) } + + return key.name.map { ($0, value) } + } let securityStringKeyedDict = Dictionary( securityKeysAndValues, uniquingKeysWith: { $1 } @@ -866,36 +870,52 @@ internal func encodeSecurity(requirements security: [Open } } -internal func decodeSecurityRequirements(from container: KeyedDecodingContainer, forKey key: CodingKeys, given optionalComponents: OpenAPI.Components?) throws -> [OpenAPI.SecurityRequirement]? { +internal func decodeSecurityRequirements(from container: KeyedDecodingContainer, forKey codingKey: CodingKeys, given optionalComponents: OpenAPI.Components?) throws -> [OpenAPI.SecurityRequirement]? { // A real mess here because we've got an Array of non-string-keyed // Dictionaries. - if container.contains(key) { - var securityContainer = try container.nestedUnkeyedContainer(forKey: key) + if container.contains(codingKey) { + var securityContainer = try container.nestedUnkeyedContainer(forKey: codingKey) var securityRequirements = [OpenAPI.SecurityRequirement]() while !securityContainer.isAtEnd { let securityStringKeyedDict = try securityContainer.decode([String: [String]].self) - // convert to JSONReference keys - let securityKeysAndValues = securityStringKeyedDict.map { (key, value) in - ( - key: JSONReference.component(named: key), - value: value - ) - } + // ultimately we end up with JSON references that may be internal + // or external. we determine if they are internal by looking them + // up in the components; if found, they are internal, otherwise, + // they are external. + let securityKeysAndValues: [(key: JSONReference, value: [String])] if let components = optionalComponents { // check each key for validity against components. let foundInComponents = { (ref: JSONReference) -> Bool in return (try? components.contains(ref)) ?? false } - guard securityKeysAndValues.map({ $0.key }).allSatisfy(foundInComponents) else { + var foundBadKeys = false + + securityKeysAndValues = securityStringKeyedDict.map { (key, value) in + let componentKey = JSONReference.component(named: key) + if foundInComponents(componentKey) { + return (componentKey, value) + } + if let url = URL(string: key) { + return (.external(url), value) + } + foundBadKeys = true + return (componentKey, value) + } + + if foundBadKeys { throw GenericError( - subjectName: key.stringValue, - details: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary", - codingPath: container.codingPath + [key] + subjectName: codingKey.stringValue, + details: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary or be a JSON reference to a security scheme found in another file", + codingPath: container.codingPath + [codingKey] ) } + } else { + securityKeysAndValues = securityStringKeyedDict.map { (key, value) in + (JSONReference.component(named: key), value) + } } securityRequirements.append(Dictionary(securityKeysAndValues, uniquingKeysWith: { $1 })) @@ -928,6 +948,10 @@ internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at p let securitySchemes = securityRequirements.flatMap { $0.keys } for securityScheme in securitySchemes { + if case .external = securityScheme { + // external references are allowed as of OAS 3.2.0 + continue + } guard components[securityScheme] != nil else { let schemeKey = securityScheme.name ?? securityScheme.absoluteString let keys = [ @@ -941,7 +965,7 @@ internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at p throw GenericError( subjectName: schemeKey, - details: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary", + details: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary or be a JSON reference to a Security Scheme found in another file", codingPath: keys ) } diff --git a/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift index 3bab740c9..d2b451f75 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift @@ -11,8 +11,13 @@ import OpenAPIKit @preconcurrency import Yams final class SecuritySchemeErrorTests: XCTestCase { - func test_missingSecuritySchemeError() { - // missing as-in not found in the Components Object + func test_missingSecuritySchemeError() throws { + #if os(Linux) && compiler(>=6.0) && compiler(<6.1) + throw XCTSkip("Swift bug causes no exception in this test case for just one Swift 6 version (6.0)") + #endif + + // missing as-in not found in the Components Object nor a valid external + // URL let documentYML = """ openapi: 3.1.0 @@ -21,15 +26,16 @@ final class SecuritySchemeErrorTests: XCTestCase { version: 1.0 paths: {} components: {} - security: - - missing: [] + security: [ + "": [] + ] """ XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `security` in the root Document object: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary.") + XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `security` in the root Document object: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary or be a JSON reference to a security scheme found in another file.") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "security" ]) @@ -37,7 +43,8 @@ final class SecuritySchemeErrorTests: XCTestCase { } func test_missingSecuritySchemeInPathsError() { - // missing as-in not found in the Components Object + // missing as-in not found in the Components Object nor a valid external + // URL let documentYML = """ openapi: 3.1.0 @@ -49,7 +56,7 @@ final class SecuritySchemeErrorTests: XCTestCase { "get": { "responses": {}, "security": [ - "hello": [] + "": [] ] } } @@ -61,13 +68,13 @@ final class SecuritySchemeErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `hello` in Document.paths['/hello/world'].get.security: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary.") + XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `` in Document.paths['/hello/world'].get.security: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary or be a JSON reference to a Security Scheme found in another file.") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", "/hello/world", "get", "security", - "hello" + "" ]) } } diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index afd10edcf..0d794d2cb 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -40,6 +40,17 @@ final class DocumentTests: XCTestCase { tags: ["hi"], externalDocs: .init(url: URL(string: "https://google.com")!) ) + + // construct with external reference to a security scheme + let _ = OpenAPI.Document( + info: .init(title: "hi", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents, + security: [ + [.external(URL(string: "https://example.com/security.yml")!): []] + ] + ) } func test_initOASVersions() { @@ -846,7 +857,10 @@ extension DocumentTests { components: .direct( securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] ), - security: [[.component( named: "security"):[]]] + security: [ + [.component(named: "security"): []], + [.external(URL(string: "https://example.com/security.yml")!): []] + ] ) let encodedDocument = try orderUnstableTestStringFromEncoding(of: document) @@ -872,6 +886,11 @@ extension DocumentTests { { "security" : [ + ] + }, + { + "https:\\/\\/example.com\\/security.yml" : [ + ] } ] @@ -880,7 +899,7 @@ extension DocumentTests { ) } - func test_specifySecurity_decode() throws { + func test_specifyInternalSecurity_decode() throws { let documentData = """ { @@ -926,6 +945,122 @@ extension DocumentTests { ) } + func test_securityNotFoundInComponentsIsExternal_decode() throws { + let documentData = + """ + { + "components" : { + }, + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1", + "paths" : { + + }, + "security" : [ + { + "security" : [ + + ] + } + ] + } + """.data(using: .utf8)! + let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData) + + XCTAssertEqual( + document, + OpenAPI.Document( + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents, + security: [[.external(URL(string: "security")!):[]]] + ) + ) + } + + func test_specifyExternalSecurity_decode() throws { + let documentData = + """ + { + "components" : { + "securitySchemes" : { + "security" : { + "in" : "header", + "name" : "key", + "type" : "apiKey" + } + } + }, + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1", + "paths" : { + + }, + "security" : [ + { + "https://example.com/security.yml": [] + } + ] + } + """.data(using: .utf8)! + let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData) + + XCTAssertEqual( + document, + OpenAPI.Document( + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .direct( + securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] + ), + security: [[.external(URL(string: "https://example.com/security.yml")!): []]] + ) + ) + } + + func test_specifyInvalidSecurity_decode() throws { + let documentData = + """ + { + "components" : { + "securitySchemes" : { + "security" : { + "in" : "header", + "name" : "key", + "type" : "apiKey" + } + } + }, + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1", + "paths" : { + + }, + "security" : [ + { + "$$$://(capture) % @url://::**$[]": [] + } + ] + } + """.data(using: .utf8)! + XCTAssertThrowsError( + try orderUnstableDecode(OpenAPI.Document.self, from: documentData) + ) { error in + XCTAssertEqual(OpenAPI.Error(from: error).localizedDescription, "Problem encountered when parsing `security` in the root Document object: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary or be a JSON reference to a security scheme found in another file.") + } + } + func test_specifyTags_encode() throws { let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), From ea169872e3de48f932d41ad64e39adb74c00d70a Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 09:38:04 -0600 Subject: [PATCH 53/73] update migration guide --- documentation/migration_guides/v5_migration_guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 245a119ec..2ec663c49 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -293,6 +293,13 @@ case .b(let positionalEncoding): } ``` +### Security Scheme Object (`OpenAPI.SecurityScheme`) +The `type` property's enumeration gains a new associated value on the `oauth2` +case. + +Existing code that switches on that property will need to be updated to match on +`oauth2(flows: OAuthFlows, metadataUrl: URL?)` now. + ### 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 From f464fa3d53f760058370eb346dec50fd3fa1c901 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 09:41:34 -0600 Subject: [PATCH 54/73] update migration guide --- documentation/migration_guides/v5_migration_guide.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 245a119ec..def87f87f 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -26,6 +26,10 @@ statements will need to be updated. If you use `v3_1_x(x: 2)` you should replace it with `v3_1_2`. +The default OAS version of a `Document` has been updated to `v3_2_0`. If you +need to produce a `v3_1_x` document, update your code to specify that version in +the `OpenAPI.Document` constructor. + ### Content Types The `application/x-yaml` media type is officially superseded by `application/yaml`. OpenAPIKit will continue to support reading the From 2e0bddf8e81881a4922b32d0c621545990403925 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 10:18:51 -0600 Subject: [PATCH 55/73] fix test failures --- Tests/OpenAPIKitTests/Document/DocumentTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 6e6f09c84..1934c46bf 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -955,7 +955,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, @@ -1015,6 +1015,7 @@ extension DocumentTests { XCTAssertEqual( document, OpenAPI.Document( + openAPIVersion: .v3_1_1, info: .init(title: "API", version: "1.0"), servers: [], paths: [:], @@ -1043,7 +1044,7 @@ extension DocumentTests { "title" : "API", "version" : "1.0" }, - "openapi" : "3.1.1", + "openapi" : "3.2.0", "paths" : { }, From 6177d03ef788d188e20335902a2f732c41815a70 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 09:56:42 -0600 Subject: [PATCH 56/73] for Swift 6.2+, require SendableMetatype conformance for the ExternalLoader type used for external dereferencing --- Sources/OpenAPIKit/ExternalLoader.swift | 13 ++++++++++++- Sources/OpenAPIKit30/ExternalLoader.swift | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKit/ExternalLoader.swift b/Sources/OpenAPIKit/ExternalLoader.swift index ba67073de..238db65c0 100644 --- a/Sources/OpenAPIKit/ExternalLoader.swift +++ b/Sources/OpenAPIKit/ExternalLoader.swift @@ -8,10 +8,21 @@ import OpenAPIKitCore import Foundation +// Once we hit language version 6.2 we get warnings (errors for Swift 6 +// language mode) if the ExternalLoader is not a SendableMetatype. We just +// split this conformance requirement out into a trivial base protocol to make +// it easy to have different behavior for different language versions. Prior to +// Swift 6.2, SendableMetatype is not even available. +#if compiler(>=6.2.0) +public protocol _ExternalLoaderMetatype: SendableMetatype {} +#else +public protocol _ExternalLoaderMetatype {} +#endif + /// An `ExternalLoader` enables `OpenAPIKit` to load external references /// without knowing the details of what decoder is being used or how new internal /// references should be named. -public protocol ExternalLoader where Message: Sendable { +public protocol ExternalLoader: _ExternalLoaderMetatype where Message: Sendable { /// This can be anything that an implementor of this protocol wants to pass back from /// the `load()` function and have available after all external loading has been done. /// diff --git a/Sources/OpenAPIKit30/ExternalLoader.swift b/Sources/OpenAPIKit30/ExternalLoader.swift index ba67073de..238db65c0 100644 --- a/Sources/OpenAPIKit30/ExternalLoader.swift +++ b/Sources/OpenAPIKit30/ExternalLoader.swift @@ -8,10 +8,21 @@ import OpenAPIKitCore import Foundation +// Once we hit language version 6.2 we get warnings (errors for Swift 6 +// language mode) if the ExternalLoader is not a SendableMetatype. We just +// split this conformance requirement out into a trivial base protocol to make +// it easy to have different behavior for different language versions. Prior to +// Swift 6.2, SendableMetatype is not even available. +#if compiler(>=6.2.0) +public protocol _ExternalLoaderMetatype: SendableMetatype {} +#else +public protocol _ExternalLoaderMetatype {} +#endif + /// An `ExternalLoader` enables `OpenAPIKit` to load external references /// without knowing the details of what decoder is being used or how new internal /// references should be named. -public protocol ExternalLoader where Message: Sendable { +public protocol ExternalLoader: _ExternalLoaderMetatype where Message: Sendable { /// This can be anything that an implementor of this protocol wants to pass back from /// the `load()` function and have available after all external loading has been done. /// From c9a6563d61701d99844f70b010aabfcf51a5802e Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 10:14:10 -0600 Subject: [PATCH 57/73] don't use a shared global decoder for concurrency reasons --- Tests/OpenAPIKit30ErrorReportingTests/Helpers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/OpenAPIKit30ErrorReportingTests/Helpers.swift b/Tests/OpenAPIKit30ErrorReportingTests/Helpers.swift index 2a3cb4e13..25933afe2 100644 --- a/Tests/OpenAPIKit30ErrorReportingTests/Helpers.swift +++ b/Tests/OpenAPIKit30ErrorReportingTests/Helpers.swift @@ -8,4 +8,4 @@ import Foundation @preconcurrency import Yams -let testDecoder = YAMLDecoder() +var testDecoder: YAMLDecoder { YAMLDecoder() } From 19c910a410149aabbe2edc6b6b2f5281d3dda8e4 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 10:55:09 -0600 Subject: [PATCH 58/73] fix any hashable sendable warnings in tests --- .../Schema Object/JSONSchemaTests.swift | 14 +++++++------- Tests/OpenAPIKit30Tests/TestHelpers.swift | 2 ++ .../Schema Object/JSONSchemaTests.swift | 14 +++++++------- Tests/OpenAPIKitTests/TestHelpers.swift | 2 ++ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift index 9c9c67ec5..82c824338 100644 --- a/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift @@ -1057,7 +1057,7 @@ final class SchemaObjectTests: XCTestCase { func test_withInitalAllowedValues() { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true, allowedValues: [false])) - let object = JSONSchema.object(.init(format: .unspecified, required: true, allowedValues: [.init([:])]), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, allowedValues: [.init(emptyStringDict)]), .init(properties: [:])) let array = JSONSchema.array(.init(format: .unspecified, required: true, allowedValues: [.init([false])]), .init(items: .boolean(.init(format: .unspecified, required: true)))) let number = JSONSchema.number(.init(format: .unspecified, required: true, allowedValues: [2.5]), .init()) let integer = JSONSchema.integer(.init(format: .unspecified, required: true, allowedValues: [5]), .init()) @@ -1065,7 +1065,7 @@ final class SchemaObjectTests: XCTestCase { let fragment = JSONSchema.fragment(.init(allowedValues: [false])) XCTAssertEqual(boolean.allowedValues, [false]) - XCTAssertEqual(object.allowedValues, [.init([:])]) + XCTAssertEqual(object.allowedValues, [.init(emptyStringDict)]) XCTAssertEqual(array.allowedValues?[0].value as! [Bool], [false]) XCTAssertEqual(number.allowedValues, [2.5]) XCTAssertEqual(integer.allowedValues, [5]) @@ -1077,7 +1077,7 @@ final class SchemaObjectTests: XCTestCase { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) .with(allowedValues: [false]) let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) - .with(allowedValues: [.init([:])]) + .with(allowedValues: [.init(emptyStringDict)]) let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean(.init(format: .unspecified, required: true)))) .with(allowedValues: [.init([false])]) let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) @@ -1118,7 +1118,7 @@ final class SchemaObjectTests: XCTestCase { func test_withInitalDefaultValue() { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true, defaultValue: false)) - let object = JSONSchema.object(.init(format: .unspecified, required: true, defaultValue: .init([:])), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, defaultValue: .init(emptyStringDict)), .init(properties: [:])) let array = JSONSchema.array(.init(format: .unspecified, required: true, defaultValue: .init([false])), .init(items: .boolean(.init(format: .unspecified, required: true)))) let number = JSONSchema.number(.init(format: .unspecified, required: true, defaultValue: 2.5), .init()) let integer = JSONSchema.integer(.init(format: .unspecified, required: true, defaultValue: 5), .init()) @@ -1126,7 +1126,7 @@ final class SchemaObjectTests: XCTestCase { let fragment = JSONSchema.fragment(.init(defaultValue: false)) XCTAssertEqual(boolean.defaultValue, false) - XCTAssertEqual(object.defaultValue, .init([:])) + XCTAssertEqual(object.defaultValue, .init(emptyStringDict)) XCTAssertEqual(array.defaultValue, .init([false])) XCTAssertEqual(number.defaultValue, 2.5) XCTAssertEqual(integer.defaultValue, 5) @@ -1138,7 +1138,7 @@ final class SchemaObjectTests: XCTestCase { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) .with(defaultValue: false) let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) - .with(defaultValue: .init([:])) + .with(defaultValue: .init(emptyStringDict)) let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean(.init(format: .unspecified, required: true)))) .with(defaultValue: .init([false])) let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) @@ -1178,7 +1178,7 @@ final class SchemaObjectTests: XCTestCase { } func test_withInitialExample() { - let object = JSONSchema.object(.init(format: .unspecified, required: true, example: .init([:])), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, example: .init(emptyStringDict)), .init(properties: [:])) let fragment = JSONSchema.fragment(.init(example: "hi")) // nonsense diff --git a/Tests/OpenAPIKit30Tests/TestHelpers.swift b/Tests/OpenAPIKit30Tests/TestHelpers.swift index 336866d5d..fb89106c7 100644 --- a/Tests/OpenAPIKit30Tests/TestHelpers.swift +++ b/Tests/OpenAPIKit30Tests/TestHelpers.swift @@ -88,3 +88,5 @@ func assertJSONEquivalent(_ str1: String?, _ str2: String?, file: StaticString = line: line ) } + +let emptyStringDict = [String: String]() diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift index 9374aefd4..900f885f8 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift @@ -1320,7 +1320,7 @@ final class SchemaObjectTests: XCTestCase { func test_withInitalAllowedValues() { let null = JSONSchema.null(.init(allowedValues: [nil])) let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true, allowedValues: [false])) - let object = JSONSchema.object(.init(format: .unspecified, required: true, allowedValues: [.init([:])]), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, allowedValues: [.init(emptyStringDict)]), .init(properties: [:])) let array = JSONSchema.array(.init(format: .unspecified, required: true, allowedValues: [.init([false])]), .init(items: .boolean(.init(format: .unspecified, required: true)))) let number = JSONSchema.number(.init(format: .unspecified, required: true, allowedValues: [2.5]), .init()) let integer = JSONSchema.integer(.init(format: .unspecified, required: true, allowedValues: [5]), .init()) @@ -1329,7 +1329,7 @@ final class SchemaObjectTests: XCTestCase { XCTAssertEqual(null.allowedValues?[0].description, "nil") XCTAssertEqual(boolean.allowedValues, [false]) - XCTAssertEqual(object.allowedValues, [.init([:])]) + XCTAssertEqual(object.allowedValues, [.init(emptyStringDict)]) XCTAssertEqual(array.allowedValues?[0].value as! [Bool], [false]) XCTAssertEqual(number.allowedValues, [2.5]) XCTAssertEqual(integer.allowedValues, [5]) @@ -1342,7 +1342,7 @@ final class SchemaObjectTests: XCTestCase { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) .with(allowedValues: [false]) let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) - .with(allowedValues: [.init([:])]) + .with(allowedValues: [.init(emptyStringDict)]) let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean(.init(format: .unspecified, required: true)))) .with(allowedValues: [.init([false])]) let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) @@ -1384,7 +1384,7 @@ final class SchemaObjectTests: XCTestCase { func test_withInitalDefaultValue() { let null = JSONSchema.null(.init(defaultValue: nil)) let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true, defaultValue: false)) - let object = JSONSchema.object(.init(format: .unspecified, required: true, defaultValue: .init([:])), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, defaultValue: .init(emptyStringDict)), .init(properties: [:])) let array = JSONSchema.array(.init(format: .unspecified, required: true, defaultValue: .init([false])), .init(items: .boolean(.init(format: .unspecified, required: true)))) let number = JSONSchema.number(.init(format: .unspecified, required: true, defaultValue: 2.5), .init()) let integer = JSONSchema.integer(.init(format: .unspecified, required: true, defaultValue: 5), .init()) @@ -1393,7 +1393,7 @@ final class SchemaObjectTests: XCTestCase { XCTAssertNil(null.defaultValue) XCTAssertEqual(boolean.defaultValue, false) - XCTAssertEqual(object.defaultValue, .init([:])) + XCTAssertEqual(object.defaultValue, .init(emptyStringDict)) XCTAssertEqual(array.defaultValue, .init([false])) XCTAssertEqual(number.defaultValue, 2.5) XCTAssertEqual(integer.defaultValue, 5) @@ -1406,7 +1406,7 @@ final class SchemaObjectTests: XCTestCase { let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) .with(defaultValue: false) let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) - .with(defaultValue: .init([:])) + .with(defaultValue: .init(emptyStringDict)) let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean(.init(format: .unspecified, required: true)))) .with(defaultValue: .init([false])) let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) @@ -1447,7 +1447,7 @@ final class SchemaObjectTests: XCTestCase { } func test_withInitialExample() { - let object = JSONSchema.object(.init(format: .unspecified, required: true, examples: [.init([:])]), .init(properties: [:])) + let object = JSONSchema.object(.init(format: .unspecified, required: true, examples: [.init(emptyStringDict)]), .init(properties: [:])) let fragment = JSONSchema.fragment(.init(examples: ["hi"])) let null = JSONSchema.null(.init(examples: ["null"])) diff --git a/Tests/OpenAPIKitTests/TestHelpers.swift b/Tests/OpenAPIKitTests/TestHelpers.swift index 336866d5d..fb89106c7 100644 --- a/Tests/OpenAPIKitTests/TestHelpers.swift +++ b/Tests/OpenAPIKitTests/TestHelpers.swift @@ -88,3 +88,5 @@ func assertJSONEquivalent(_ str1: String?, _ str2: String?, file: StaticString = line: line ) } + +let emptyStringDict = [String: String]() From af2f293a8edf51756129fb6b3832cc8a7a13cec3 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 12 Dec 2025 20:34:35 -0600 Subject: [PATCH 59/73] fix more Swift 6 warnings --- Tests/OpenAPIKit30Tests/TestHelpers.swift | 24 +++++++++---------- Tests/OpenAPIKitCoreTests/TestHelpers.swift | 12 +++++----- .../Helpers.swift | 2 +- Tests/OpenAPIKitTests/TestHelpers.swift | 24 +++++++++---------- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Tests/OpenAPIKit30Tests/TestHelpers.swift b/Tests/OpenAPIKit30Tests/TestHelpers.swift index fb89106c7..dfb2abb4c 100644 --- a/Tests/OpenAPIKit30Tests/TestHelpers.swift +++ b/Tests/OpenAPIKit30Tests/TestHelpers.swift @@ -9,7 +9,7 @@ import Foundation @preconcurrency import Yams import XCTest -fileprivate let foundationTestEncoder = { () -> JSONEncoder in +fileprivate func foundationTestEncoder() -> JSONEncoder { let encoder = JSONEncoder() if #available(macOS 10.13, *) { encoder.dateEncodingStrategy = .iso8601 @@ -22,25 +22,25 @@ fileprivate let foundationTestEncoder = { () -> JSONEncoder in encoder.outputFormatting = [.prettyPrinted, .sortedKeys] #endif return encoder -}() +} func orderUnstableEncode(_ value: T) throws -> Data { - return try foundationTestEncoder.encode(value) + return try foundationTestEncoder().encode(value) } func orderUnstableTestStringFromEncoding(of entity: T) throws -> String? { return String(data: try orderUnstableEncode(entity), encoding: .utf8) } -fileprivate let yamsTestEncoder = { () -> YAMLEncoder in +fileprivate func yamsTestEncoder() -> YAMLEncoder { return YAMLEncoder() -}() +} func orderStableYAMLEncode(_ value: T) throws -> String { - return try yamsTestEncoder.encode(value) + return try yamsTestEncoder().encode(value) } -fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: Any] = [:]) -> JSONDecoder { +fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: any Sendable] = [:]) -> JSONDecoder { let decoder = JSONDecoder() decoder.userInfo = userInfo if #available(macOS 10.12, *) { @@ -54,18 +54,18 @@ fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: Any] return decoder } -fileprivate let foundationTestDecoder = { () -> JSONDecoder in buildFoundationTestDecoder() }() +fileprivate func foundationTestDecoder() -> JSONDecoder { buildFoundationTestDecoder() } -func orderUnstableDecode(_ type: T.Type, from data: Data, userInfo : [CodingUserInfoKey: Any] = [:]) throws -> T { +func orderUnstableDecode(_ type: T.Type, from data: Data, userInfo : [CodingUserInfoKey: any Sendable] = [:]) throws -> T { return try buildFoundationTestDecoder(userInfo).decode(T.self, from: data) } -fileprivate let yamsTestDecoder = { () -> YAMLDecoder in +fileprivate func yamsTestDecoder() -> YAMLDecoder { return YAMLDecoder() -}() +} func orderStableDecode(_ type: T.Type, from data: Data) throws -> T { - return try yamsTestDecoder.decode(T.self, from: data) + return try yamsTestDecoder().decode(T.self, from: data) } func assertJSONEquivalent(_ str1: String?, _ str2: String?, file: StaticString = #file, line: UInt = #line) { diff --git a/Tests/OpenAPIKitCoreTests/TestHelpers.swift b/Tests/OpenAPIKitCoreTests/TestHelpers.swift index ae5ca1fa1..ed75573fb 100644 --- a/Tests/OpenAPIKitCoreTests/TestHelpers.swift +++ b/Tests/OpenAPIKitCoreTests/TestHelpers.swift @@ -8,7 +8,7 @@ import Foundation import XCTest -fileprivate let foundationTestEncoder = { () -> JSONEncoder in +fileprivate func foundationTestEncoder() -> JSONEncoder { let encoder = JSONEncoder() if #available(macOS 10.13, *) { encoder.dateEncodingStrategy = .iso8601 @@ -21,10 +21,10 @@ fileprivate let foundationTestEncoder = { () -> JSONEncoder in encoder.outputFormatting = [.prettyPrinted, .sortedKeys] #endif return encoder -}() +} func orderUnstableEncode(_ value: T) throws -> Data { - return try foundationTestEncoder.encode(value) + return try foundationTestEncoder().encode(value) } func orderUnstableTestStringFromEncoding(of entity: T) throws -> String? { @@ -35,7 +35,7 @@ func orderUnstableTestStringFromEncoding(of entity: T) throws -> S // return try fineJSONTestEncoder.encode(value) //} -fileprivate let foundationTestDecoder = { () -> JSONDecoder in +fileprivate func foundationTestDecoder() -> JSONDecoder { let decoder = JSONDecoder() if #available(macOS 10.12, *) { decoder.dateDecodingStrategy = .iso8601 @@ -46,10 +46,10 @@ fileprivate let foundationTestDecoder = { () -> JSONDecoder in decoder.keyDecodingStrategy = .useDefaultKeys #endif return decoder -}() +} func orderUnstableDecode(_ type: T.Type, from data: Data) throws -> T { - return try foundationTestDecoder.decode(T.self, from: data) + return try foundationTestDecoder().decode(T.self, from: data) } //func orderStableDecode(_ type: T.Type, from data: Data) throws -> T { diff --git a/Tests/OpenAPIKitErrorReportingTests/Helpers.swift b/Tests/OpenAPIKitErrorReportingTests/Helpers.swift index 2a3cb4e13..25933afe2 100644 --- a/Tests/OpenAPIKitErrorReportingTests/Helpers.swift +++ b/Tests/OpenAPIKitErrorReportingTests/Helpers.swift @@ -8,4 +8,4 @@ import Foundation @preconcurrency import Yams -let testDecoder = YAMLDecoder() +var testDecoder: YAMLDecoder { YAMLDecoder() } diff --git a/Tests/OpenAPIKitTests/TestHelpers.swift b/Tests/OpenAPIKitTests/TestHelpers.swift index fb89106c7..dfb2abb4c 100644 --- a/Tests/OpenAPIKitTests/TestHelpers.swift +++ b/Tests/OpenAPIKitTests/TestHelpers.swift @@ -9,7 +9,7 @@ import Foundation @preconcurrency import Yams import XCTest -fileprivate let foundationTestEncoder = { () -> JSONEncoder in +fileprivate func foundationTestEncoder() -> JSONEncoder { let encoder = JSONEncoder() if #available(macOS 10.13, *) { encoder.dateEncodingStrategy = .iso8601 @@ -22,25 +22,25 @@ fileprivate let foundationTestEncoder = { () -> JSONEncoder in encoder.outputFormatting = [.prettyPrinted, .sortedKeys] #endif return encoder -}() +} func orderUnstableEncode(_ value: T) throws -> Data { - return try foundationTestEncoder.encode(value) + return try foundationTestEncoder().encode(value) } func orderUnstableTestStringFromEncoding(of entity: T) throws -> String? { return String(data: try orderUnstableEncode(entity), encoding: .utf8) } -fileprivate let yamsTestEncoder = { () -> YAMLEncoder in +fileprivate func yamsTestEncoder() -> YAMLEncoder { return YAMLEncoder() -}() +} func orderStableYAMLEncode(_ value: T) throws -> String { - return try yamsTestEncoder.encode(value) + return try yamsTestEncoder().encode(value) } -fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: Any] = [:]) -> JSONDecoder { +fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: any Sendable] = [:]) -> JSONDecoder { let decoder = JSONDecoder() decoder.userInfo = userInfo if #available(macOS 10.12, *) { @@ -54,18 +54,18 @@ fileprivate func buildFoundationTestDecoder(_ userInfo: [CodingUserInfoKey: Any] return decoder } -fileprivate let foundationTestDecoder = { () -> JSONDecoder in buildFoundationTestDecoder() }() +fileprivate func foundationTestDecoder() -> JSONDecoder { buildFoundationTestDecoder() } -func orderUnstableDecode(_ type: T.Type, from data: Data, userInfo : [CodingUserInfoKey: Any] = [:]) throws -> T { +func orderUnstableDecode(_ type: T.Type, from data: Data, userInfo : [CodingUserInfoKey: any Sendable] = [:]) throws -> T { return try buildFoundationTestDecoder(userInfo).decode(T.self, from: data) } -fileprivate let yamsTestDecoder = { () -> YAMLDecoder in +fileprivate func yamsTestDecoder() -> YAMLDecoder { return YAMLDecoder() -}() +} func orderStableDecode(_ type: T.Type, from data: Data) throws -> T { - return try yamsTestDecoder.decode(T.self, from: data) + return try yamsTestDecoder().decode(T.self, from: data) } func assertJSONEquivalent(_ str1: String?, _ str2: String?, file: StaticString = #file, line: UInt = #line) { From 635f0a1bdf4f11a08e625acac5ad72858114f734 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 10:48:56 -0600 Subject: [PATCH 60/73] move Discriminator object out of shared --- Sources/OpenAPIKit/Discriminator.swift | 53 +++++++++++++++++++ Sources/OpenAPIKit/_CoreReExport.swift | 1 - .../Discriminator.swift | 14 ++--- Sources/OpenAPIKit30/_CoreReExport.swift | 1 - Sources/OpenAPIKitCompat/Compat30To31.swift | 10 +++- .../DocumentConversionTests.swift | 7 ++- 6 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 Sources/OpenAPIKit/Discriminator.swift rename Sources/{OpenAPIKitCore/Shared => OpenAPIKit30}/Discriminator.swift (83%) diff --git a/Sources/OpenAPIKit/Discriminator.swift b/Sources/OpenAPIKit/Discriminator.swift new file mode 100644 index 000000000..d2ca3af06 --- /dev/null +++ b/Sources/OpenAPIKit/Discriminator.swift @@ -0,0 +1,53 @@ +// +// Discriminator.swift +// +// +// Created by Mathew Polzin on 10/6/19. +// + +import OpenAPIKitCore + +extension OpenAPI { + /// OpenAPI Spec "Discriminator Object" + /// + /// See [OpenAPI Discriminator Object](https://spec.openapis.org/oas/v3.2.0.html#discriminator-object). + public struct Discriminator: Equatable, Sendable { + public let propertyName: String + public let mapping: OrderedDictionary? + + public init(propertyName: String, + mapping: OrderedDictionary? = nil) { + self.propertyName = propertyName + self.mapping = mapping + } + } +} + +// MARK: - Codable + +extension OpenAPI.Discriminator: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(propertyName, forKey: .propertyName) + try container.encodeIfPresent(mapping, forKey: .mapping) + } +} + +extension OpenAPI.Discriminator: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + propertyName = try container.decode(String.self, forKey: .propertyName) + mapping = try container.decodeIfPresent(OrderedDictionary.self, forKey: .mapping) + } +} + +extension OpenAPI.Discriminator { + private enum CodingKeys: String, CodingKey { + case propertyName + case mapping + } +} + +extension OpenAPI.Discriminator: Validatable {} diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index e4d850978..0c78947a0 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -22,7 +22,6 @@ public extension OpenAPI { typealias Warning = OpenAPIKitCore.Warning typealias Path = OpenAPIKitCore.Shared.Path typealias ComponentKey = OpenAPIKitCore.Shared.ComponentKey - typealias Discriminator = OpenAPIKitCore.Shared.Discriminator typealias CallbackURL = OpenAPIKitCore.Shared.CallbackURL } diff --git a/Sources/OpenAPIKitCore/Shared/Discriminator.swift b/Sources/OpenAPIKit30/Discriminator.swift similarity index 83% rename from Sources/OpenAPIKitCore/Shared/Discriminator.swift rename to Sources/OpenAPIKit30/Discriminator.swift index b1c1716bd..99d8aa3ee 100644 --- a/Sources/OpenAPIKitCore/Shared/Discriminator.swift +++ b/Sources/OpenAPIKit30/Discriminator.swift @@ -5,8 +5,10 @@ // Created by Mathew Polzin on 10/6/19. // -extension Shared { - /// OpenAPI Spec "Disciminator Object" +import OpenAPIKitCore + +extension OpenAPI { + /// OpenAPI Spec "Discriminator Object" /// /// See [OpenAPI Discriminator Object](https://spec.openapis.org/oas/v3.0.4.html#discriminator-object). public struct Discriminator: Equatable, Sendable { @@ -23,7 +25,7 @@ extension Shared { // MARK: - Codable -extension Shared.Discriminator: Encodable { +extension OpenAPI.Discriminator: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -32,7 +34,7 @@ extension Shared.Discriminator: Encodable { } } -extension Shared.Discriminator: Decodable { +extension OpenAPI.Discriminator: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -41,11 +43,11 @@ extension Shared.Discriminator: Decodable { } } -extension Shared.Discriminator { +extension OpenAPI.Discriminator { private enum CodingKeys: String, CodingKey { case propertyName case mapping } } -extension Shared.Discriminator: Validatable {} +extension OpenAPI.Discriminator: Validatable {} diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index e4d850978..0c78947a0 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -22,7 +22,6 @@ public extension OpenAPI { typealias Warning = OpenAPIKitCore.Warning typealias Path = OpenAPIKitCore.Shared.Path typealias ComponentKey = OpenAPIKitCore.Shared.ComponentKey - typealias Discriminator = OpenAPIKitCore.Shared.Discriminator typealias CallbackURL = OpenAPIKitCore.Shared.CallbackURL } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 3746f3e16..19bda0961 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -527,6 +527,12 @@ extension OpenAPIKit30.JSONTypeFormat: To31 { } } +extension OpenAPIKit30.OpenAPI.Discriminator: To31 { + fileprivate func to31() -> OpenAPIKit.OpenAPI.Discriminator { + .init(propertyName: propertyName, mapping: mapping) + } +} + extension OpenAPIKit30.JSONSchema.CoreContext: To31 where Format: OpenAPIKit.OpenAPIFormat { fileprivate func to31() -> OpenAPIKit.JSONSchema.CoreContext { OpenAPIKit.JSONSchema.CoreContext( @@ -537,7 +543,7 @@ extension OpenAPIKit30.JSONSchema.CoreContext: To31 where Format: OpenAPIKit.Ope deprecated: deprecated, title: title, description: description, - discriminator: discriminator, + discriminator: discriminator?.to31(), externalDocs: externalDocs?.to31(), allowedValues: allowedValues, defaultValue: defaultValue, @@ -557,7 +563,7 @@ extension OpenAPIKit30.JSONSchema.CoreContext where Format == OpenAPIKit30.JSONT deprecated: deprecated, title: title, description: description, - discriminator: discriminator, + discriminator: discriminator?.to31(), externalDocs: externalDocs?.to31(), allowedValues: allowedValues, defaultValue: defaultValue, diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 4b0b5653d..b35a4e064 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1210,7 +1210,7 @@ fileprivate func assertEqualNewToOld(_ newCoreContext: OpenAPIKit.JSONSchemaCont XCTAssertEqual(newCoreContext.nullable, oldCoreContext.nullable) XCTAssertEqual(newCoreContext.title, oldCoreContext.title) XCTAssertEqual(newCoreContext.description, oldCoreContext.description) - XCTAssertEqual(newCoreContext.discriminator, oldCoreContext.discriminator) + try assertEqualNewToOld(newCoreContext.discriminator, oldCoreContext.discriminator) try assertEqualNewToOld(newCoreContext.externalDocs, oldCoreContext.externalDocs) XCTAssertEqual(newCoreContext.allowedValues, oldCoreContext.allowedValues) XCTAssertEqual(newCoreContext.defaultValue, oldCoreContext.defaultValue) @@ -1278,6 +1278,11 @@ fileprivate func assertEqualNewToOld(_ newStyle: OpenAPIKit.OpenAPI.Parameter.Sc } } +fileprivate func assertEqualNewToOld(_ newDiscriminator: OpenAPIKit.OpenAPI.Discriminator?, _ oldDiscriminator: OpenAPIKit30.OpenAPI.Discriminator?) throws { + XCTAssertEqual(newDiscriminator?.mapping, oldDiscriminator?.mapping) + XCTAssertEqual(newDiscriminator?.propertyName, oldDiscriminator?.propertyName) +} + fileprivate func assertEqualNewToOld(_ newHeader: OpenAPIKit.OpenAPI.Header, _ oldHeader: OpenAPIKit30.OpenAPI.Header) throws { XCTAssertEqual(newHeader.description, oldHeader.description) XCTAssertEqual(newHeader.required, oldHeader.required) From a0b2d9164098f93102abe37a5f5155b2ddc4a844 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 11:06:44 -0600 Subject: [PATCH 61/73] Add OAS 3.2.0 defaultMapping field to Discriminator Object --- Sources/OpenAPIKit/Discriminator.swift | 37 ++++++++++++++++- .../OpenAPIKitTests/DiscriminatorTests.swift | 41 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKit/Discriminator.swift b/Sources/OpenAPIKit/Discriminator.swift index d2ca3af06..6d30b5304 100644 --- a/Sources/OpenAPIKit/Discriminator.swift +++ b/Sources/OpenAPIKit/Discriminator.swift @@ -11,18 +11,44 @@ extension OpenAPI { /// OpenAPI Spec "Discriminator Object" /// /// See [OpenAPI Discriminator Object](https://spec.openapis.org/oas/v3.2.0.html#discriminator-object). - public struct Discriminator: Equatable, Sendable { + public struct Discriminator: HasConditionalWarnings, Sendable { public let propertyName: String public let mapping: OrderedDictionary? + public let defaultMapping: String? + + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] public init(propertyName: String, - mapping: OrderedDictionary? = nil) { + mapping: OrderedDictionary? = nil, + defaultMapping: String? = nil) { self.propertyName = propertyName self.mapping = mapping + self.defaultMapping = defaultMapping + + self.conditionalWarnings = [ + nonNilVersionWarning(fieldName: "defaultMapping", value: defaultMapping, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +extension OpenAPI.Discriminator: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.propertyName == rhs.propertyName + && lhs.mapping == rhs.mapping + && lhs.defaultMapping == rhs.defaultMapping + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Discriminator \(fieldName) field" + ) + } +} + // MARK: - Codable extension OpenAPI.Discriminator: Encodable { @@ -31,6 +57,7 @@ extension OpenAPI.Discriminator: Encodable { try container.encode(propertyName, forKey: .propertyName) try container.encodeIfPresent(mapping, forKey: .mapping) + try container.encodeIfPresent(defaultMapping, forKey: .defaultMapping) } } @@ -40,6 +67,11 @@ extension OpenAPI.Discriminator: Decodable { propertyName = try container.decode(String.self, forKey: .propertyName) mapping = try container.decodeIfPresent(OrderedDictionary.self, forKey: .mapping) + defaultMapping = try container.decodeIfPresent(String.self, forKey: .defaultMapping) + + conditionalWarnings = [ + nonNilVersionWarning(fieldName: "defaultMapping", value: defaultMapping, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } @@ -47,6 +79,7 @@ extension OpenAPI.Discriminator { private enum CodingKeys: String, CodingKey { case propertyName case mapping + case defaultMapping } } diff --git a/Tests/OpenAPIKitTests/DiscriminatorTests.swift b/Tests/OpenAPIKitTests/DiscriminatorTests.swift index 3f05462a7..5d1d555e7 100644 --- a/Tests/OpenAPIKitTests/DiscriminatorTests.swift +++ b/Tests/OpenAPIKitTests/DiscriminatorTests.swift @@ -14,11 +14,21 @@ final class DiscriminatorTests: XCTestCase { let t1 = OpenAPI.Discriminator(propertyName: "hello world") XCTAssertEqual(t1.propertyName, "hello world") XCTAssertNil(t1.mapping) + XCTAssertEqual(t1.conditionalWarnings.count, 0) let t2 = OpenAPI.Discriminator(propertyName: "hello world", mapping: ["hello": "world"]) XCTAssertEqual(t2.propertyName, "hello world") XCTAssertEqual(t2.mapping, ["hello": "world"]) + XCTAssertEqual(t2.conditionalWarnings.count, 0) + + let t3 = OpenAPI.Discriminator(propertyName: "hello world", + mapping: ["hello": "world"], + defaultMapping: "world") + XCTAssertEqual(t3.propertyName, "hello world") + XCTAssertEqual(t3.mapping, ["hello": "world"]) + XCTAssertEqual(t3.defaultMapping, "world") + XCTAssertEqual(t3.conditionalWarnings.count, 1) } } @@ -85,4 +95,35 @@ extension DiscriminatorTests { XCTAssertEqual(discriminator, OpenAPI.Discriminator(propertyName: "hello", mapping: [ "hello": "world"])) } + + func test_withDefaultMapping_encode() { + let discriminator = OpenAPI.Discriminator(propertyName: "hello", + defaultMapping: "world") + let encodedDiscriminator = try! orderUnstableTestStringFromEncoding(of: discriminator) + + assertJSONEquivalent( + encodedDiscriminator, + """ + { + "defaultMapping" : "world", + "propertyName" : "hello" + } + """ + ) + } + + func test_withDefaultMapping_decode() { + let discriminatorData = + """ + { + "defaultMapping" : "world", + "propertyName": "hello", + } + """.data(using: .utf8)! + + let discriminator = try! orderUnstableDecode(OpenAPI.Discriminator.self, from: discriminatorData) + + XCTAssertEqual(discriminator, OpenAPI.Discriminator(propertyName: "hello", + defaultMapping: "world")) + } } From 71a965f0f6140cc2a53b93c53c6b95f29153e0a7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 11:43:26 -0600 Subject: [PATCH 62/73] add mediaTypes to components --- .../Components+Locatable.swift | 5 ++++ .../Components Object/Components.swift | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift index 441ba805f..8392277db 100644 --- a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift +++ b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift @@ -64,6 +64,11 @@ extension OpenAPI.Link: ComponentDictionaryLocatable { public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.links) } } +extension OpenAPI.Content: ComponentDictionaryLocatable { + public static var openAPIComponentsKey: String { "mediaTypes" } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.mediaTypes) } +} + extension OpenAPI.PathItem: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "pathItems" } public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .a(\.pathItems) } diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 035644c43..89d6cd460 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -56,6 +56,8 @@ extension OpenAPI { public var securitySchemes: ComponentReferenceDictionary public var links: ComponentReferenceDictionary public var callbacks: ComponentReferenceDictionary + /// Media Type Objects (aka `OpenAPI.Content`) + public var mediaTypes: ComponentReferenceDictionary public var pathItems: ComponentDictionary @@ -76,6 +78,7 @@ extension OpenAPI { securitySchemes: ComponentReferenceDictionary = [:], links: ComponentReferenceDictionary = [:], callbacks: ComponentReferenceDictionary = [:], + mediaTypes: ComponentReferenceDictionary = [:], pathItems: ComponentDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { @@ -88,6 +91,7 @@ extension OpenAPI { self.securitySchemes = securitySchemes self.links = links self.callbacks = callbacks + self.mediaTypes = mediaTypes self.pathItems = pathItems self.vendorExtensions = vendorExtensions } @@ -105,6 +109,7 @@ extension OpenAPI { securitySchemes: ComponentDictionary = [:], links: ComponentDictionary = [:], callbacks: ComponentDictionary = [:], + mediaTypes: ComponentDictionary = [:], pathItems: ComponentDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) -> Self { @@ -118,6 +123,7 @@ extension OpenAPI { securitySchemes: securitySchemes.mapValues { .b($0) }, links: links.mapValues { .b($0) }, callbacks: callbacks.mapValues { .b($0) }, + mediaTypes: mediaTypes.mapValues { .b($0) }, pathItems: pathItems, vendorExtensions: vendorExtensions ) @@ -170,6 +176,7 @@ extension OpenAPI.Components { try securitySchemes.merge(other.securitySchemes, uniquingKeysWith: detectCollision(type: "securitySchemes")) try links.merge(other.links, uniquingKeysWith: detectCollision(type: "links")) try callbacks.merge(other.callbacks, uniquingKeysWith: detectCollision(type: "callbacks")) + try mediaTypes.merge(other.mediaTypes, uniquingKeysWith: detectCollision(type: "mediaTypes")) try pathItems.merge(other.pathItems, uniquingKeysWith: detectCollision(type: "pathItems")) try vendorExtensions.merge(other.vendorExtensions, uniquingKeysWith: detectCollision(type: "vendorExtensions")) } @@ -185,6 +192,7 @@ extension OpenAPI.Components { securitySchemes.sortKeys() links.sortKeys() callbacks.sortKeys() + mediaTypes.sortKeys() pathItems.sortKeys() } } @@ -237,6 +245,10 @@ extension OpenAPI.Components: Encodable { if !callbacks.isEmpty { try container.encode(callbacks, forKey: .callbacks) } + + if !mediaTypes.isEmpty { + try container.encode(mediaTypes, forKey: .mediaTypes) + } if !pathItems.isEmpty { try container.encode(pathItems, forKey: .pathItems) @@ -276,6 +288,8 @@ extension OpenAPI.Components: Decodable { links = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .links) ?? [:] callbacks = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .callbacks) ?? [:] + + mediaTypes = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .mediaTypes) ?? [:] pathItems = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .pathItems) ?? [:] @@ -310,6 +324,7 @@ extension OpenAPI.Components { case securitySchemes case links case callbacks + case mediaTypes case pathItems case extended(String) @@ -325,6 +340,7 @@ extension OpenAPI.Components { .securitySchemes, .links, .callbacks, + .mediaTypes, .pathItems ] } @@ -353,6 +369,8 @@ extension OpenAPI.Components { self = .links case "callbacks": self = .callbacks + case "mediaTypes": + self = .mediaTypes case "pathItems": self = .pathItems default: @@ -380,6 +398,8 @@ extension OpenAPI.Components { return "links" case .callbacks: return "callbacks" + case .mediaTypes: + return "mediaTypes" case .pathItems: return "pathItems" case .extended(let key): @@ -409,6 +429,7 @@ extension OpenAPI.Components { let oldSecuritySchemes = securitySchemes let oldLinks = links let oldCallbacks = callbacks + let oldMediaTypes = mediaTypes let oldPathItems = pathItems async let (newSchemas, c1, m1) = oldSchemas.externallyDereferenced(with: loader) @@ -420,7 +441,8 @@ extension OpenAPI.Components { async let (newSecuritySchemes, c7, m7) = oldSecuritySchemes.externallyDereferenced(with: loader) // async let (newLinks, c8, m8) = oldLinks.externallyDereferenced(with: loader) // async let (newCallbacks, c9, m9) = oldCallbacks.externallyDereferenced(with: loader) - async let (newPathItems, c10, m10) = oldPathItems.externallyDereferenced(with: loader) + async let (newMediaTypes, c10, m10) = oldMediaTypes.externallyDereferenced(with: loader) + async let (newPathItems, c11, m11) = oldPathItems.externallyDereferenced(with: loader) schemas = try await newSchemas responses = try await newResponses @@ -431,6 +453,7 @@ extension OpenAPI.Components { securitySchemes = try await newSecuritySchemes // links = try await newLinks // callbacks = try await newCallbacks + mediaTypes = try await newMediaTypes pathItems = try await newPathItems let c1Resolved = try await c1 @@ -443,6 +466,7 @@ extension OpenAPI.Components { // let c8Resolved = try await c8 // let c9Resolved = try await c9 let c10Resolved = try await c10 + let c11Resolved = try await c11 // For Swift 5.10+ we can delete the following links and callbacks code and uncomment the // preferred code above. @@ -464,8 +488,9 @@ extension OpenAPI.Components { && c8Resolved.isEmpty && c9Resolved.isEmpty && c10Resolved.isEmpty + && c11Resolved.isEmpty - let newMessages = try await context + m1 + m2 + m3 + m4 + m5 + m6 + m7 + m8 + m9 + m10 + let newMessages = try await context + m1 + m2 + m3 + m4 + m5 + m6 + m7 + m8 + m9 + m10 + m11 if noNewComponents { return newMessages } @@ -479,6 +504,7 @@ extension OpenAPI.Components { try merge(c8Resolved) try merge(c9Resolved) try merge(c10Resolved) + try merge(c11Resolved) switch depth { case .iterations(let number): From e8500d3f6fdcd7cf7aabbedbc816674f6b7a8dec Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 21:18:24 -0600 Subject: [PATCH 63/73] Add components conditional warning for use of mediaTypes map before version 3.2.0 of the spec --- .../Components Object/Components.swift | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 89d6cd460..49c28636f 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -45,7 +45,7 @@ extension OpenAPI { /// "my_param": .reference(.component(named: "my_direct_param")) /// ] /// ) - public struct Components: Equatable, CodableVendorExtendable, Sendable { + public struct Components: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var schemas: ComponentDictionary public var responses: ComponentReferenceDictionary @@ -68,6 +68,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( schemas: ComponentDictionary = [:], responses: ComponentReferenceDictionary = [:], @@ -94,6 +96,10 @@ extension OpenAPI { self.mediaTypes = mediaTypes self.pathItems = pathItems self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + nonEmptyVersionWarning(fieldName: "mediaTypes", value: mediaTypes, minimumVersion: .v3_2_0) + ].compactMap { $0 } } /// Construct components as "direct" entries (no references). When @@ -138,6 +144,32 @@ extension OpenAPI { } } +extension OpenAPI.Components: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.schemas == rhs.schemas + && lhs.responses == rhs.responses + && lhs.parameters == rhs.parameters + && lhs.examples == rhs.examples + && lhs.requestBodies == rhs.requestBodies + && lhs.headers == rhs.headers + && lhs.securitySchemes == rhs.securitySchemes + && lhs.links == rhs.links + && lhs.callbacks == rhs.callbacks + && lhs.mediaTypes == rhs.mediaTypes + && lhs.pathItems == rhs.pathItems + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonEmptyVersionWarning(fieldName: String, value: any Collection, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + if value.isEmpty { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Components \(fieldName) map" + ) +} + extension OpenAPI { public typealias ComponentDictionary = OrderedDictionary @@ -294,6 +326,10 @@ extension OpenAPI.Components: Decodable { pathItems = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .pathItems) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = [ + nonEmptyVersionWarning(fieldName: "mediaTypes", value: mediaTypes, minimumVersion: .v3_2_0) + ].compactMap { $0 } } catch let error as EitherDecodeNoTypesMatchedError { if let underlyingError = OpenAPI.Error.Decoding.Document.eitherBranchToDigInto(error) { throw (underlyingError.underlyingError ?? underlyingError) From e7de241570e4c6645a1a084c2fbb65e6acb0c0b4 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 21:43:59 -0600 Subject: [PATCH 64/73] Add convenience constructor for either that can hold a Content value --- Sources/OpenAPIKit/Either/Either+Convenience.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index 4fb851c6f..977c79daa 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -221,6 +221,11 @@ extension Either where B == OpenAPI.Parameter { public static func parameter(_ parameter: OpenAPI.Parameter) -> Self { .b(parameter) } } +extension Either where B == OpenAPI.Content { + /// Construct content. + public static func content(_ content: OpenAPI.Content) -> Self { .b(content) } +} + extension Either where B == OpenAPI.Content.Map { /// Construct a content map. public static func content(_ map: OpenAPI.Content.Map) -> Self { .b(map) } From 1b1a40abf0b2be96a8511b8a5f306b9cac74badf Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 21:44:20 -0600 Subject: [PATCH 65/73] Add tests for new Components mediaTypes property --- Tests/OpenAPIKitTests/ComponentsTests.swift | 49 +++++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 0d15cca4c..31ae5e77e 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -67,8 +67,11 @@ final class ComponentsTests: XCTestCase { ) ]) ], + mediaTypes: [ + "ten": .content(.init(schema: .string)) + ], pathItems: [ - "ten": .init(get: .init(responses: [200: .response(description: "response")])) + "eleven": .init(get: .init(responses: [200: .response(description: "response")])) ], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) @@ -114,8 +117,11 @@ final class ComponentsTests: XCTestCase { ) ] ], + mediaTypes: [ + "ten": .init(schema: .string) + ], pathItems: [ - "ten": .init(get: .init(responses: [200: .response(description: "response")])) + "eleven": .init(get: .init(responses: [200: .response(description: "response")])) ], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) @@ -246,8 +252,11 @@ final class ComponentsTests: XCTestCase { OpenAPI.CallbackURL(rawValue: "{$url}")!: .pathItem(.init(post: .init(responses: [:]))) ] ], + mediaTypes: [ + "ten": .init(schema: .string) + ], pathItems: [ - "ten": .init(get: .init(responses: [:])) + "eleven": .init(get: .init(responses: [:])) ] ) @@ -260,7 +269,8 @@ final class ComponentsTests: XCTestCase { let ref7 = try components.reference(named: "seven", ofType: OpenAPI.SecurityScheme.self) let ref8 = try components.reference(named: "eight", ofType: OpenAPI.Link.self) let ref9 = try components.reference(named: "nine", ofType: OpenAPI.Callbacks.self) - let ref10 = try components.reference(named: "ten", ofType: OpenAPI.PathItem.self) + let ref10 = try components.reference(named: "ten", ofType: OpenAPI.Content.self) + let ref11 = try components.reference(named: "eleven", ofType: OpenAPI.PathItem.self) XCTAssertEqual(components[ref1], .string) XCTAssertEqual(components[ref2], .init(description: "hello", content: [:])) @@ -276,7 +286,8 @@ final class ComponentsTests: XCTestCase { OpenAPI.CallbackURL(rawValue: "{$url}")!: .pathItem(.init(post: .init(responses: [:]))) ] ) - XCTAssertEqual(components[ref10], .init(get: .init(responses: [:]))) + XCTAssertEqual(components[ref10], .init(schema: .string)) + XCTAssertEqual(components[ref11], .init(get: .init(responses: [:]))) } func test_subscriptLookup() throws { @@ -502,8 +513,11 @@ extension ComponentsTests { ) ] ], + mediaTypes: [ + "ten": .init(schema: .string) + ], pathItems: [ - "ten": .init(get: .init(responses: [200: .response(description: "response")])) + "eleven": .init(get: .init(responses: [200: .response(description: "response")])) ], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) @@ -544,6 +558,13 @@ extension ComponentsTests { "operationId" : "op1" } }, + "mediaTypes" : { + "ten" : { + "schema" : { + "type" : "string" + } + } + }, "parameters" : { "three" : { "content" : { @@ -554,7 +575,7 @@ extension ComponentsTests { } }, "pathItems" : { - "ten" : { + "eleven" : { "get" : { "responses" : { "200" : { @@ -630,6 +651,13 @@ extension ComponentsTests { "operationId" : "op1" } }, + "mediaTypes" : { + "ten" : { + "schema" : { + "type" : "string" + } + } + }, "parameters" : { "three" : { "content" : { @@ -640,7 +668,7 @@ extension ComponentsTests { } }, "pathItems" : { - "ten" : { + "eleven" : { "get" : { "responses" : { "200" : { @@ -724,8 +752,11 @@ extension ComponentsTests { ) ] ], + mediaTypes: [ + "ten": .init(schema: .string) + ], pathItems: [ - "ten": .init(get: .init(responses: [200: .response(description: "response")])) + "eleven": .init(get: .init(responses: [200: .response(description: "response")])) ], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) From 8b7651f0d98dad98fb280f0d638927a600bd13d4 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 13 Dec 2025 21:50:08 -0600 Subject: [PATCH 66/73] support refernces in Content.Map values --- Sources/OpenAPIKit/Content/Content.swift | 10 +- .../Content/DereferencedContent.swift | 10 +- .../Either/Either+Convenience.swift | 5 + .../Header/DereferencedHeader.swift | 8 +- .../Parameter/DereferencedParameter.swift | 8 +- Sources/OpenAPIKitCompat/Compat30To31.swift | 6 +- .../DocumentConversionTests.swift | 4 + .../RequestContentMapErrorTests.swift | 44 +------- .../Content/ContentTests.swift | 4 +- .../Document/DocumentTests.swift | 18 +-- Tests/OpenAPIKitTests/EaseOfUseTests.swift | 105 ++++++++++-------- .../Header/DereferencedHeaderTests.swift | 4 +- .../OpenAPIKitTests/Header/HeaderTests.swift | 18 +-- .../DereferencedOperationTests.swift | 8 +- .../Operation/OperationTests.swift | 8 +- .../DereferencedParameterTests.swift | 8 +- .../Parameter/ParameterTests.swift | 14 +-- .../Request/DereferencedRequestTests.swift | 6 +- .../Request/RequestTests.swift | 85 ++++++++------ .../Response/DereferencedResponseTests.swift | 6 +- .../Response/ResponseTests.swift | 12 +- .../Validator/BuiltinValidationTests.swift | 48 ++++---- .../Validator/ValidatorTests.swift | 50 ++++----- 23 files changed, 256 insertions(+), 233 deletions(-) diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index d394250b6..ac87d8f1b 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -319,7 +319,15 @@ extension OpenAPI.Content { } extension OpenAPI.Content { - public typealias Map = OrderedDictionary + public typealias Map = OrderedDictionary, OpenAPI.Content>> +} + +extension OpenAPI.Content.Map { + /// Construct an OpenAPI.Content.Map for which none of the values are + /// references (all values are OpenAPI.Content). + public static func direct(_ map: OrderedDictionary) -> OpenAPI.Content.Map { + map.mapValues { .b($0) } + } } extension OpenAPI.Content { diff --git a/Sources/OpenAPIKit/Content/DereferencedContent.swift b/Sources/OpenAPIKit/Content/DereferencedContent.swift index d122422e5..9a0016298 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContent.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContent.swift @@ -37,7 +37,8 @@ public struct DereferencedContent: Equatable { internal init( _ content: OpenAPI.Content, resolvingIn components: OpenAPI.Components, - following references: Set + following references: Set, + dereferencedFromComponentNamed name: String? ) throws { self.schema = try content.schema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) self.itemSchema = try content.itemSchema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) @@ -67,6 +68,11 @@ public struct DereferencedContent: Equatable { self.encoding = nil } + var content = content + if let name { + content.vendorExtensions[OpenAPI.Components.componentNameExtension] = .init(name) + } + self.underlyingContent = content } @@ -85,7 +91,7 @@ extension OpenAPI.Content: LocallyDereferenceable { following references: Set, dereferencedFromComponentNamed name: String? ) throws -> DereferencedContent { - return try DereferencedContent(self, resolvingIn: components, following: references) + return try DereferencedContent(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index 977c79daa..dade06100 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -103,6 +103,11 @@ extension Either where B == OpenAPI.Link { public var linkValue: B? { b } } +extension Either where B == OpenAPI.Content { + /// Retrieve the content if that is what this property contains. + public var contentValue: B? { b } +} + extension Either where B == OpenAPI.Content.Map { /// Retrieve the content map if that is what this property contains. public var contentValue: B? { b } diff --git a/Sources/OpenAPIKit/Header/DereferencedHeader.swift b/Sources/OpenAPIKit/Header/DereferencedHeader.swift index 40509234a..0972264e3 100644 --- a/Sources/OpenAPIKit/Header/DereferencedHeader.swift +++ b/Sources/OpenAPIKit/Header/DereferencedHeader.swift @@ -47,10 +47,10 @@ public struct DereferencedHeader: Equatable { case .b(let contentMap): self.schemaOrContent = .b( try contentMap.mapValues { - try DereferencedContent( - $0, - resolvingIn: components, - following: references + try $0._dereferenced( + in: components, + following: references, + dereferencedFromComponentNamed: nil ) } ) diff --git a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift index 23c079e82..963eb7487 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift @@ -49,10 +49,10 @@ public struct DereferencedParameter: Equatable { case .b(let contentMap): self.schemaOrContent = .b( try contentMap.mapValues { - try DereferencedContent( - $0, - resolvingIn: components, - following: references + try $0._dereferenced( + in: components, + following: references, + dereferencedFromComponentNamed: nil ) } ) diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 19bda0961..91e8b257f 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -116,7 +116,7 @@ extension Either where A == OpenAPIKit30.OpenAPI.Parameter.SchemaContext, B == O case .a(let context): .a(context.to31()) case .b(let contentMap): - .b(contentMap.mapValues { $0.to31() }) + .b(contentMap.mapValues { .b($0.to31()) }) } } } @@ -328,7 +328,7 @@ extension OpenAPIKit30.OpenAPI.Response: To31 { OpenAPIKit.OpenAPI.Response( description: description, headers: headers?.mapValues(eitherRefTo31), - content: content.mapValues { $0.to31() }, + content: content.mapValues { .b($0.to31()) }, links: links.mapValues(eitherRefTo31), vendorExtensions: vendorExtensions ) @@ -339,7 +339,7 @@ extension OpenAPIKit30.OpenAPI.Request: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Request { OpenAPIKit.OpenAPI.Request( description: description, - content: content.mapValues { $0.to31() }, + content: content.mapValues { .b($0.to31()) }, required: `required`, vendorExtensions: vendorExtensions ) diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index b35a4e064..01b7fd6ae 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1086,6 +1086,10 @@ fileprivate func assertEqualNewToOld(_ newRequest: OpenAPIKit.OpenAPI.Request, _ fileprivate func assertEqualNewToOld(_ newContentMap: OpenAPIKit.OpenAPI.Content.Map, _ oldContentMap: OpenAPIKit30.OpenAPI.Content.Map) throws { for ((newCt, newContent), (oldCt, oldContent)) in zip(newContentMap, oldContentMap) { XCTAssertEqual(newCt, oldCt) + guard case let .b(newContent) = newContent else { + XCTFail("Expected new content not to be a reference but found a reference: \(newContent)") + return + } switch (newContent.schema?.value, oldContent.schema) { case (.reference(let ref1, _), .a(let ref2)): XCTAssertEqual(ref1.absoluteString, ref2.absoluteString) diff --git a/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift index 7771fe7a6..c5e251818 100644 --- a/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift @@ -11,46 +11,6 @@ import OpenAPIKit @preconcurrency import Yams final class RequestContentMapErrorTests: XCTestCase { - /** - - The "wrong type content map key" does not fail right now because OrderedDictionary does not - throw for keys it cannot decode. This is a shortcoming of OrderedDictionary that should be solved - in a future release. - - */ - -// func test_wrongTypeContentMapKey() { -// let documentYML = -//""" -//openapi: "3.1.0" -//info: -// title: test -// version: 1.0 -//paths: -// /hello/world: -// get: -// requestBody: -// content: -// blablabla: -// schema: -// $ref: #/components/schemas/one -// responses: {} -//""" -// -// XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in -// -// let openAPIError = OpenAPI.Error(from: error) -// -// XCTAssertEqual(openAPIError.localizedDescription, "Expected to find either a $ref or a Request in .requestBody for the **GET** endpoint under `/hello/world` but found neither. \n\nJSONReference could not be decoded because:\nExpected `requestBody` value in .paths./hello/world.get to be parsable as Mapping but it was not.\n\nRequest could not be decoded because:\nExpected `requestBody` value in .paths./hello/world.get to be parsable as Mapping but it was not..") -// XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ -// "paths", -// "/hello/world", -// "get", -// "requestBody" -// ]) -// } -// } - func test_wrongTypeContentValue() { let documentYML = """ @@ -71,7 +31,7 @@ final class RequestContentMapErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Expected `application/json` value in .content for the request body of the **GET** endpoint under `/hello/world` to be parsable as Mapping but it was not.") + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Content in .content['application/json'] for the request body of the **GET** endpoint under `/hello/world`. \n\nReference could not be decoded because:\nExpected value to be parsable as Mapping but it was not.\n\nContent could not be decoded because:\nExpected value to be parsable as Mapping but it was not..") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", "/hello/world", @@ -107,7 +67,7 @@ final class RequestContentMapErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `Vendor Extension` in .content['application/json'] for the request body of the **GET** endpoint under `/hello/world`: Found at least one vendor extension property that does not begin with the required 'x-' prefix. Invalid properties: [ invalid ].") + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Content in .content['application/json'] for the request body of the **GET** endpoint under `/hello/world`. \n\nContent could not be decoded because:\nProblem encountered when parsing `Vendor Extension`: Found at least one vendor extension property that does not begin with the required 'x-' prefix. Invalid properties: [ invalid ]..") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", "/hello/world", diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index 7a5a3d197..e1cdca2ef 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -163,7 +163,7 @@ final class ContentTests: XCTestCase { } func test_contentMap() { - let _: OpenAPI.Content.Map = [ + let _: OpenAPI.Content.Map = .direct([ .bmp: .init(schema: .init(.string(contentEncoding: .binary))), .css: .init(schema: .init(.string)), .csv: .init(schema: .init(.string)), @@ -197,7 +197,7 @@ final class ContentTests: XCTestCase { .anyVideo: .init(schema: .string(contentEncoding: .binary)), .any: .init(schema: .string(contentEncoding: .binary)) - ] + ]) } } diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 1934c46bf..c515bac83 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -258,7 +258,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [] ) ) @@ -280,7 +280,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [] ) ) @@ -302,7 +302,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [s1, s2], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [] ) ) @@ -324,7 +324,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [s1, s2] ) ) @@ -346,7 +346,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [s1, s2], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [s1, s2] ) ) @@ -369,7 +369,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [s2], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [s3] ) ) @@ -393,7 +393,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [s2, s4], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [s3] ) ) @@ -417,7 +417,7 @@ final class DocumentTests: XCTestCase { "/hello/world": .init( servers: [s2, s4], get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])], + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])], servers: [s3] ) ) @@ -438,7 +438,7 @@ final class DocumentTests: XCTestCase { paths: [ "/hello/world": .init( get: .init( - responses: [.default: .response(description: "test", content: [.json: .init(schema: .string)])] + responses: [.default: .response(description: "test", content: [.json: .content(.init(schema: .string))])] ) ) ], diff --git a/Tests/OpenAPIKitTests/EaseOfUseTests.swift b/Tests/OpenAPIKitTests/EaseOfUseTests.swift index 917d06544..ee476324c 100644 --- a/Tests/OpenAPIKitTests/EaseOfUseTests.swift +++ b/Tests/OpenAPIKitTests/EaseOfUseTests.swift @@ -65,13 +65,16 @@ final class DeclarativeEaseOfUseTests: XCTestCase { 200: .response( description: "Successful Retrieve", content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] - ), - example: #"{ "hello": "world" }"# + .json: .content( + .init( + schema: + .object( + properties: [ + "hello": .string + ] + ), + example: #"{ "hello": "world" }"# + ) ) ] ) @@ -84,11 +87,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [], requestBody: .init( content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] + .json: .content( + .init( + schema: .object( + properties: [ + "hello": .string + ] + ) ) ) ] @@ -97,11 +102,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { 202: .response( description: "Successful Create", content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] + .json: .content( + .init( + schema: .object( + properties: [ + "hello": .string + ] + ) ) ) ] @@ -185,13 +192,15 @@ final class DeclarativeEaseOfUseTests: XCTestCase { 200: .response( description: "Successful Retrieve", content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] - ), - example: #"{ "hello": "world" }"# + .json: .content( + .init( + schema: .object( + properties: [ + "hello": .string + ] + ), + example: #"{ "hello": "world" }"# + ) ) ] ) @@ -205,11 +214,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [], requestBody: .init( content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] + .json: .content( + .init( + schema: .object( + properties: [ + "hello": .string + ] + ) ) ) ] @@ -218,11 +229,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { 202: .response( description: "Successful Create", content: [ - .json: .init( - schema: .object( - properties: [ - "hello": .string - ] + .json: .content( + .init( + schema: .object( + properties: [ + "hello": .string + ] + ) ) ) ] @@ -404,7 +417,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { let successfulHelloResponse = OpenAPI.Response( description: "Hello", content: [ - .txt: .init(schemaReference: try components.reference(named: "hello_string", ofType: JSONSchema.self)) + .txt: .content(.init(schemaReference: try components.reference(named: "hello_string", ofType: JSONSchema.self))) ] ) @@ -463,7 +476,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { let endpoint = document.paths["/widgets/{id}"]?.pathItemValue?.get let response = endpoint?.responses[status: 200]?.responseValue - let responseSchemaReference = response?.content[.json]?.schema + let responseSchemaReference = response?.content[.json]?.contentValue?.schema // this response schema is a reference found in the Components Object. We dereference // it to get at the schema. let responseSchema = responseSchemaReference.flatMap { document.components[$0] } @@ -476,7 +489,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { let endpoint = document.paths["/widgets/{id}"]?.pathItemValue?.post let request = endpoint?.requestBody?.requestValue - let requestSchemaReference = request?.content[.json]?.schema + let requestSchemaReference = request?.content[.json]?.contentValue?.schema // this request schema is defined inline but dereferencing still produces the schema // (dereferencing is just a no-op in this case). let requestSchema = requestSchemaReference.flatMap { document.components[$0] } @@ -531,7 +544,7 @@ fileprivate let testDocument = OpenAPI.Document( 200: .response( description: "A single widget", content: [ - .json: .init(schemaReference: .component(named: "testWidgetSchema")) + .json: .content(.init(schemaReference: .component(named: "testWidgetSchema"))) ] ) ] @@ -542,11 +555,13 @@ fileprivate let testDocument = OpenAPI.Document( description: "Create a new widget by adding a description. The created widget will be returned in the response body including a new part number.", requestBody: OpenAPI.Request( content: [ - .json: .init( - schema: JSONSchema.object( - properties: [ - "description": .string - ] + .json: .content( + .init( + schema: JSONSchema.object( + properties: [ + "description": .string + ] + ) ) ) ] @@ -555,7 +570,7 @@ fileprivate let testDocument = OpenAPI.Document( 201: .response( description: "The newly created widget", content: [ - .json: .init(schemaReference: .component(named: "testWidgetSchema")) + .json: .content(.init(schemaReference: .component(named: "testWidgetSchema"))) ] ) ] @@ -568,7 +583,7 @@ fileprivate let testDocument = OpenAPI.Document( 200: .response( description: "Get documentation on this API.", content: [ - .html: .init(schema: .string) + .html: .content(.init(schema: .string)) ] ) ] diff --git a/Tests/OpenAPIKitTests/Header/DereferencedHeaderTests.swift b/Tests/OpenAPIKitTests/Header/DereferencedHeaderTests.swift index a1d9424c9..8d153bf4b 100644 --- a/Tests/OpenAPIKitTests/Header/DereferencedHeaderTests.swift +++ b/Tests/OpenAPIKitTests/Header/DereferencedHeaderTests.swift @@ -22,7 +22,7 @@ final class DereferencedHeaderTests: XCTestCase { func test_inlineContentHeader() throws { let t1 = try OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ).dereferenced(in: .noComponents) @@ -52,7 +52,7 @@ final class DereferencedHeaderTests: XCTestCase { ] ) let t1 = try OpenAPI.Header( - content: [.json: .init(schemaReference: .component(named: "test"))] + content: [.json: .content(.init(schemaReference: .component(named: "test")))] ).dereferenced(in: components) XCTAssertEqual( diff --git a/Tests/OpenAPIKitTests/Header/HeaderTests.swift b/Tests/OpenAPIKitTests/Header/HeaderTests.swift index d54ee70f7..304344b12 100644 --- a/Tests/OpenAPIKitTests/Header/HeaderTests.swift +++ b/Tests/OpenAPIKitTests/Header/HeaderTests.swift @@ -11,7 +11,7 @@ import OpenAPIKit final class HeaderTests: XCTestCase { func test_init() { let contentMap: OpenAPI.Content.Map = [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] let t1 = OpenAPI.Header(schemaOrContent: .init(contentMap)) @@ -64,7 +64,7 @@ final class HeaderTests: XCTestCase { extension HeaderTests { func test_header_contentMap_encode() throws { let header = OpenAPI.Header(content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ]) let headerEncoding = try orderUnstableTestStringFromEncoding(of: header) @@ -103,7 +103,7 @@ extension HeaderTests { XCTAssertEqual( header, OpenAPI.Header(content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ]) ) } @@ -217,7 +217,7 @@ extension HeaderTests { func test_header_required_encode() throws { let header = OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], required: true ) @@ -261,7 +261,7 @@ extension HeaderTests { header, OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], required: true ) @@ -271,7 +271,7 @@ extension HeaderTests { func test_header_withDescription_encode() throws { let header = OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], description: "hello" ) @@ -315,7 +315,7 @@ extension HeaderTests { header, OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], description: "hello" ) @@ -325,7 +325,7 @@ extension HeaderTests { func test_header_deprecated_encode() throws { let header = OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], deprecated: true ) @@ -369,7 +369,7 @@ extension HeaderTests { header, OpenAPI.Header( content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], deprecated: true ) diff --git a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift index cc4cbc2c9..c4552ae19 100644 --- a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift @@ -28,13 +28,13 @@ final class DereferencedOperationTests: XCTestCase { context: .header(schema: .string) ) ], - requestBody: OpenAPI.Request(content: [.json: .init(schema: .string)]), + requestBody: OpenAPI.Request(content: [.json: .content(.init(schema: .string))]), responses: [ 200: .response(description: "test") ] ).dereferenced(in: .noComponents) XCTAssertEqual(t1.parameters.count, 1) - XCTAssertEqual(t1.requestBody?.underlyingRequest, OpenAPI.Request(content: [.json: .init(schema: .string)])) + XCTAssertEqual(t1.requestBody?.underlyingRequest, OpenAPI.Request(content: [.json: .content(.init(schema: .string))])) XCTAssertEqual(t1.responses.count, 1) XCTAssertEqual(t1.responseOutcomes.first?.response, t1.responses[status: 200]) XCTAssertEqual(t1.responseOutcomes.first?.status, 200) @@ -79,7 +79,7 @@ final class DereferencedOperationTests: XCTestCase { func test_requestReference() throws { let components = OpenAPI.Components.direct( requestBodies: [ - "test": OpenAPI.Request(content: [.json: .init(schema: .string)]) + "test": OpenAPI.Request(content: [.json: .content(.init(schema: .string))]) ] ) let t1 = try OpenAPI.Operation( @@ -91,7 +91,7 @@ final class DereferencedOperationTests: XCTestCase { XCTAssertEqual( t1.requestBody?.underlyingRequest, OpenAPI.Request( - content: [.json: .init(schema: .string)], + content: [.json: .content(.init(schema: .string))], vendorExtensions: ["x-component-name": "test"] ) ) diff --git a/Tests/OpenAPIKitTests/Operation/OperationTests.swift b/Tests/OpenAPIKitTests/Operation/OperationTests.swift index 6cd175652..912b13451 100644 --- a/Tests/OpenAPIKitTests/Operation/OperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/OperationTests.swift @@ -116,7 +116,7 @@ extension OperationTests { parameters: [ .reference(.component(named: "hello")) ], - requestBody: .init(content: [.json: .init(schema: .init(.string))]), + requestBody: .init(content: [.json: .content(.init(schema: .init(.string)))]), responses: [200: .reference(.component(named: "test"))], callbacks: [ "callback": .init( @@ -290,7 +290,7 @@ extension OperationTests { parameters: [ .reference(.component(named: "hello")) ], - requestBody: .init(content: [.json: .init(schema: .init(.string))]), + requestBody: .init(content: [.json: .content(.init(schema: .init(.string)))]), responses: [200: .reference(.component(named: "test"))], callbacks: [ "callback": .init( @@ -317,9 +317,9 @@ extension OperationTests { ) // compare request to construction of Either - XCTAssertEqual(operation.requestBody, .request(.init(content: [.json: .init(schema: .init(.string))]))) + XCTAssertEqual(operation.requestBody, .request(.init(content: [.json: .content(.init(schema: .init(.string)))]))) // compare request having extracted from Either - XCTAssertEqual(operation.requestBody?.requestValue, .init(content: [.json: .init(schema: .init(.string))])) + XCTAssertEqual(operation.requestBody?.requestValue, .init(content: [.json: .content(.init(schema: .init(.string)))])) XCTAssertNil(operation.responses[200]?.responseValue) XCTAssertEqual(operation.responses[200]?.reference, .component(named: "test")) diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift index 559491a89..ccdc106ad 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift @@ -27,13 +27,13 @@ final class DereferencedParameterTests: XCTestCase { let t2 = try OpenAPI.Parameter.path( name: "test2", content: [ - .anyText: .init(schema: .string) + .anyText: .content(.init(schema: .string)) ] ).dereferenced(in: .noComponents) XCTAssertEqual(t2.name, "test2") XCTAssertEqual(t2.context, .path(content: [ - .anyText: .init(schema: .string) + .anyText: .content(.init(schema: .string)) ])) XCTAssertEqual( t2.schemaOrContent.contentValue, @@ -49,7 +49,7 @@ final class DereferencedParameterTests: XCTestCase { let t1 = try OpenAPI.Parameter.header( name: "test", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ).dereferenced(in: .noComponents) @@ -81,7 +81,7 @@ final class DereferencedParameterTests: XCTestCase { ) let t1 = try OpenAPI.Parameter.header( name: "test", - content: [.json: .init(schemaReference: .component(named: "test"))] + content: [.json: .content(.init(schemaReference: .component(named: "test")))] ).dereferenced(in: components) XCTAssertEqual( diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift index 554779cde..e2eb0c66d 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift @@ -13,7 +13,7 @@ final class ParameterTests: XCTestCase { let t1 = OpenAPI.Parameter.cookie( name: "hello", required: true, - schemaOrContent: .init([.json: OpenAPI.Content(schema: .string)]), + schemaOrContent: .init([.json: .content(OpenAPI.Content(schema: .string))]), description: "hi", deprecated: true ) @@ -22,7 +22,7 @@ final class ParameterTests: XCTestCase { let t2 = OpenAPI.Parameter.cookie( name: "hello", required: true, - schemaOrContent: .content([.json: OpenAPI.Content(schema: .string)]), + schemaOrContent: .content([.json: .content(OpenAPI.Content(schema: .string))]), description: "hi", deprecated: true ) @@ -80,8 +80,8 @@ final class ParameterTests: XCTestCase { let t1: OpenAPI.Parameter.Array = [ .parameter(OpenAPI.Parameter.cookie(name: "hello", schema: .string)), .parameter(name: "hello", context: .cookie(schema: .string)), - .parameter(OpenAPI.Parameter.cookie(name: "hello", content: [.json: OpenAPI.Content(schema: .string)])), - .parameter(name: "hello", context: .cookie(content: [.json: OpenAPI.Content(schema: .string)])), + .parameter(OpenAPI.Parameter.cookie(name: "hello", content: [.json: .content(OpenAPI.Content(schema: .string))])), + .parameter(name: "hello", context: .cookie(content: [.json: .content(OpenAPI.Content(schema: .string))])), .reference(.component( named: "hello")) ] @@ -107,7 +107,7 @@ extension ParameterTests { func test_minimalContent_encode() throws { let parameter = OpenAPI.Parameter.path( name: "hello", - content: [ .json: .init(schema: .string)] + content: [ .json: .content(.init(schema: .string))] ) let encodedParameter = try orderUnstableTestStringFromEncoding(of: parameter) @@ -154,10 +154,10 @@ extension ParameterTests { parameter, OpenAPI.Parameter.path( name: "hello", - content: [ .json: .init(schema: .string)] + content: [ .json: .content(.init(schema: .string))] ) ) - XCTAssertEqual(parameter.schemaOrContent.contentValue, [ .json: .init(schema: .string) ]) + XCTAssertEqual(parameter.schemaOrContent.contentValue, [ .json: .content(.init(schema: .string) )]) } func test_minimalSchema_encode() throws { diff --git a/Tests/OpenAPIKitTests/Request/DereferencedRequestTests.swift b/Tests/OpenAPIKitTests/Request/DereferencedRequestTests.swift index 822ce2f98..8369a6241 100644 --- a/Tests/OpenAPIKitTests/Request/DereferencedRequestTests.swift +++ b/Tests/OpenAPIKitTests/Request/DereferencedRequestTests.swift @@ -22,7 +22,7 @@ final class DereferencedRequestTests: XCTestCase { func test_allInlinedComponents() throws { let t1 = try OpenAPI.Request( description: "test", - content: [.json: .init(schema: .string)] + content: [.json: .content(.init(schema: .string))] ).dereferenced(in: .noComponents) XCTAssertEqual(t1.content[.json]?.schema?.jsonSchema, .string) // test dynamic member lookup @@ -38,7 +38,7 @@ final class DereferencedRequestTests: XCTestCase { let t1 = try OpenAPI.Request( description: "test", content: [ - .json: .init(schemaReference: .component(named: "test")) + .json: .content(.init(schemaReference: .component(named: "test"))) ] ).dereferenced(in: components) XCTAssertEqual( @@ -52,7 +52,7 @@ final class DereferencedRequestTests: XCTestCase { try OpenAPI.Request( description: "test", content: [ - .json: .init(schemaReference: .component(named: "test")) + .json: .content(.init(schemaReference: .component(named: "test"))) ] ).dereferenced(in: .noComponents) ) diff --git a/Tests/OpenAPIKitTests/Request/RequestTests.swift b/Tests/OpenAPIKitTests/Request/RequestTests.swift index efa58f603..0d717b2df 100644 --- a/Tests/OpenAPIKitTests/Request/RequestTests.swift +++ b/Tests/OpenAPIKitTests/Request/RequestTests.swift @@ -21,24 +21,31 @@ final class RequestTests: XCTestCase { content: [:], required: true) - let _ = OpenAPI.Request(description: "A Request", - content: [ - .json: .init(schema: .init(simpleSchema)) - + let _ = OpenAPI.Request( + description: "A Request", + content: [ + .json: .content(.init(schema: .init(simpleSchema))) ], - required: false) + required: false + ) - let _ = OpenAPI.Request(content: [ - .xml: .init(schema: .init(simpleSchema)) - ]) + let _ = OpenAPI.Request( + content: [ + .xml: .content(.init(schema: .init(simpleSchema))) + ] + ) - let _ = OpenAPI.Request(content: [ - .form: .init(schema: .init(simpleSchema)) - ]) + let _ = OpenAPI.Request( + content: [ + .form: .content(.init(schema: .init(simpleSchema))) + ] + ) - let _ = OpenAPI.Request(content: [ - .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) - ]) + let _ = OpenAPI.Request( + content: [ + .json: .content(.init(schema: .reference(.external(URL(string: "hello.json#/world")!)))) + ] + ) } } @@ -60,9 +67,11 @@ extension RequestTests { } func test_onlyReferenceContent_encode() { - let request = OpenAPI.Request(content: [ - .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) - ]) + let request = OpenAPI.Request( + content: [ + .json: .content(.init(schema: .reference(.external(URL(string: "hello.json#/world")!)))) + ] + ) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) assertJSONEquivalent( @@ -85,9 +94,13 @@ extension RequestTests { let requestData = #"{ "content": { "application/json": { "schema": { "$ref": "hello.json#/world" } } } }"#.data(using: .utf8)! let request = try! orderUnstableDecode(OpenAPI.Request.self, from: requestData) - XCTAssertEqual(request, OpenAPI.Request(content: [ - .json : .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) - ])) + XCTAssertEqual(request, + OpenAPI.Request( + content: [ + .json : .content(.init(schema: .reference(.external(URL(string: "hello.json#/world")!)))) + ] + ) + ) } func test_onlySchemaContent_encode() { @@ -97,7 +110,7 @@ extension RequestTests { ] ) let request = OpenAPI.Request(content: [ - .json: .init(schema: .init(schema)) + .json: .content(.init(schema: .init(schema))) ]) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) @@ -147,10 +160,12 @@ extension RequestTests { request, OpenAPI.Request( content: [ - .json : .init( - schema: .init( - .object( - properties: ["hello": .string(required: false)] + .json : .content( + .init( + schema: .init( + .object( + properties: ["hello": .string(required: false)] + ) ) ) ) @@ -289,7 +304,7 @@ extension RequestTests { ] ) let request = OpenAPI.Request(content: [ - .xml: .init(schema: .init(schema)) + .xml: .content(.init(schema: .init(schema))) ]) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) @@ -339,9 +354,11 @@ extension RequestTests { request, OpenAPI.Request( content: [ - .xml : .init( - schema: .init( - .object(properties: ["hello": .string(required: false)]) + .xml : .content( + .init( + schema: .init( + .object(properties: ["hello": .string(required: false)]) + ) ) ) ] @@ -356,7 +373,7 @@ extension RequestTests { ] ) let request = OpenAPI.Request(content: [ - .form: .init(schema: .init(schema)) + .form: .content(.init(schema: .init(schema))) ]) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) @@ -406,9 +423,11 @@ extension RequestTests { request, OpenAPI.Request( content: [ - .form : .init( - schema: .init( - .object(properties: ["hello": .string(required: false)]) + .form : .content( + .init( + schema: .init( + .object(properties: ["hello": .string(required: false)]) + ) ) ) ] diff --git a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift index 8d15a86ff..3b42bc1b1 100644 --- a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift @@ -24,7 +24,7 @@ final class DereferencedResponseTests: XCTestCase { "Header": .header(.init(schema: .string)) ], content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ], links: [ "Link": .link(operationId: "link1") @@ -73,7 +73,7 @@ final class DereferencedResponseTests: XCTestCase { let t1 = try OpenAPI.Response( description: "test", content: [ - .json: .init(schemaReference: .component(named: "test")) + .json: .content(.init(schemaReference: .component(named: "test"))) ] ).dereferenced(in: components) XCTAssertEqual( @@ -87,7 +87,7 @@ final class DereferencedResponseTests: XCTestCase { try OpenAPI.Response( description: "test", content: [ - .json: .init(schemaReference: .component(named: "test")) + .json: .content(.init(schemaReference: .component(named: "test"))) ] ).dereferenced(in: .noComponents) ) diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index fcc27d71f..1be788365 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -21,10 +21,10 @@ final class ResponseTests: XCTestCase { let header = OpenAPI.Header(schemaOrContent: .init(.header(.string))) let r2 = OpenAPI.Response(description: "", headers: ["hello": .init(header)], - content: [.json: content]) + content: [.json: .content(content)]) XCTAssertEqual(r2.description, "") XCTAssertEqual(r2.headers?["hello"]?.headerValue, header) - XCTAssertEqual(r2.content, [.json: content]) + XCTAssertEqual(r2.content, [.json: .content(content)]) XCTAssertEqual(r2.conditionalWarnings.count, 0) // two OAS 3.2.0 warnings: summary is used and description is not @@ -209,7 +209,7 @@ extension ResponseTests { let response = OpenAPI.Response( description: "hello world", headers: ["hello": .init(header)], - content: [.json: content] + content: [.json: .content(content)] ) let encodedResponse = try! orderUnstableTestStringFromEncoding(of: response) @@ -259,7 +259,7 @@ extension ResponseTests { OpenAPI.Response( description: "hello world", headers: ["hello": .init(header)], - content: [.json: content] + content: [.json: .content(content)] ) ) } @@ -324,7 +324,7 @@ extension ResponseTests { let response = OpenAPI.Response( description: "hello world", headers: ["hello": .init(header)], - content: [.json: content], + content: [.json: .content(content)], vendorExtensions: [ "x-specialFeature": true ] ) @@ -377,7 +377,7 @@ extension ResponseTests { OpenAPI.Response( description: "hello world", headers: ["hello": .init(header)], - content: [.json: content], + content: [.json: .content(content)], vendorExtensions: [ "x-specialFeature": true ] ) ) diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 0f9bac4fa..72c446a63 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -94,9 +94,9 @@ final class BuiltinValidationTests: XCTestCase { 200: .response( description: "Test", content: [ - .json: .init( + .json: .content(.init( schema: .fragment(.init()) - ) + )) ] ) ] @@ -121,11 +121,13 @@ final class BuiltinValidationTests: XCTestCase { 200: .response( description: "Test", content: [ - .json: .init( - schema: .object( - properties: [ - "nested": .fragment(.init()) - ] + .json: .content( + .init( + schema: .object( + properties: [ + "nested": .fragment(.init()) + ] + ) ) ) ] @@ -157,11 +159,13 @@ final class BuiltinValidationTests: XCTestCase { 200: .response( description: "Test", content: [ - .json: .init( - // following is _not_ an empty schema component - // because it is actually a `{ "type": "object" }` - // instead of a `{ }` - schema: .object + .json: .content( + .init( + // following is _not_ an empty schema component + // because it is actually a `{ "type": "object" }` + // instead of a `{ }` + schema: .object + ) ) ] ) @@ -731,13 +735,15 @@ final class BuiltinValidationTests: XCTestCase { description: "response2", headers: ["header1": .reference(.component(named: "header1"))], content: [ - .json: .init( - schema: .string, - examples: [ - "example1": .reference(.component(named: "example1")) - ] + .json: .content( + .init( + schema: .string, + examples: [ + "example1": .reference(.component(named: "example1")) + ] + ) ), - .xml: .init(schemaReference: .component(named: "schema1")) + .xml: .content(.init(schemaReference: .component(named: "schema1"))) ], links: ["linky": .reference(.component(named: "link1"))] ) @@ -806,7 +812,7 @@ final class BuiltinValidationTests: XCTestCase { "header1": .reference(.component(named: "header1")), "external": .reference(.external(URL(string: "https://website.com/file.json#/hello/world")!)) ], - content: [ + content: .direct([ .json: .init( schema: .string, examples: [ @@ -816,7 +822,7 @@ final class BuiltinValidationTests: XCTestCase { ), .xml: .init(schemaReference: .component(named: "schema1")), .txt: .init(schemaReference: .external(URL(string: "https://website.com/file.json#/hello/world")!)) - ], + ]), links: [ "linky": .reference(.component(named: "link1")), "linky2": .reference(.external(URL(string: "https://linky.com")!)) @@ -851,7 +857,7 @@ final class BuiltinValidationTests: XCTestCase { "example1": .init(value: .b("hello")) ], requestBodies: [ - "request1": .init(content: [.json: .init(schema: .object)]) + "request1": .init(content: [.json: .content(.init(schema: .object))]) ], headers: [ "header1": .init(schema: .string) diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index 54bb6f7b5..57b475ac7 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -71,8 +71,8 @@ final class ValidatorTests: XCTestCase { ) let _ = Validation( - check: unwrap(\OpenAPI.Content.Map[.json], into: contentValidation), - when: \OpenAPI.Content.Map[.json] != nil + check: unwrap(\OpenAPI.Content.Map[.json]?.contentValue, into: contentValidation), + when: \OpenAPI.Content.Map[.json]?.contentValue != nil ) } @@ -541,7 +541,7 @@ final class ValidatorTests: XCTestCase { 200: .response( description: "Get the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ) ] @@ -573,13 +573,13 @@ final class ValidatorTests: XCTestCase { 200: .response( description: "Get the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ), 404: .response( description: "Leave the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ) ] @@ -616,13 +616,13 @@ final class ValidatorTests: XCTestCase { 200: .response( description: "Get the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ), 404: .response( description: "Leave the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ) ] @@ -655,7 +655,7 @@ final class ValidatorTests: XCTestCase { 200: .response( description: "Get the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ) ] @@ -765,7 +765,7 @@ final class ValidatorTests: XCTestCase { 200: .response( description: "Get the world", content: [ - .json: .init(schema: .string) + .json: .content(.init(schema: .string)) ] ) ] @@ -1094,26 +1094,26 @@ final class ValidatorTests: XCTestCase { let createRequest = OpenAPI.Request( content: [ - .json: .init( + .json: .content(.init( schema: .object( properties: [ "classification": .string(allowedValues: "big", "small") ] ) - ) + )) ] ) let successCreateResponse = OpenAPI.Response( description: "Created Widget", content: [ - .json: .init( + .json: .content(.init( schema: .object( properties: [ "classification": .string(allowedValues: "big", "small") ] ) - ) + )) ] ) @@ -1157,20 +1157,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema, + \.content[.json]?.contentValue?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema != nil + when: \OpenAPI.Request.content[.json]?.contentValue?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema, + \.content[.json]?.contentValue?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema != nil + when: \OpenAPI.Response.content[.json]?.contentValue?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( @@ -1221,21 +1221,21 @@ final class ValidatorTests: XCTestCase { func test_requestBodySchemaValidationSucceeds() throws { let createRequest = OpenAPI.Request( content: [ - .json: .init( + .json: .content(.init( schema: .object( properties: [ "name": .string, "classification": .string(allowedValues: "big", "small") ] ) - ) + )) ] ) let successCreateResponse = OpenAPI.Response( description: "Created Widget", content: [ - .json: .init( + .json: .content(.init( schema: .object( properties: [ "id": .integer, @@ -1243,7 +1243,7 @@ final class ValidatorTests: XCTestCase { "classification": .string(allowedValues: "big", "small") ] ) - ) + )) ] ) @@ -1287,20 +1287,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema, + \.content[.json]?.contentValue?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema != nil + when: \OpenAPI.Request.content[.json]?.contentValue?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema, + \.content[.json]?.contentValue?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema != nil + when: \OpenAPI.Response.content[.json]?.contentValue?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( From 4184e4f9595ea3dbba2e273b34a56471ad0a2b9b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 17 Dec 2025 10:47:24 -0600 Subject: [PATCH 67/73] fix error handling under new content map structure --- .../ResponseDecodingError.swift | 5 +++++ Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift | 8 +++++--- .../OpenAPIKitErrorReportingTests/SchemaErrorTests.swift | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIKit/Encoding and Decoding Errors/ResponseDecodingError.swift b/Sources/OpenAPIKit/Encoding and Decoding Errors/ResponseDecodingError.swift index 48259de94..c23ad3af8 100644 --- a/Sources/OpenAPIKit/Encoding and Decoding Errors/ResponseDecodingError.swift +++ b/Sources/OpenAPIKit/Encoding and Decoding Errors/ResponseDecodingError.swift @@ -70,6 +70,11 @@ extension OpenAPI.Error.Decoding.Response { internal init(_ error: GenericError) { var codingPath = Self.relativePath(from: error.codingPath) + + if error.pathIncludesSubject { + codingPath = codingPath.dropLast() + } + let code = codingPath.removeFirst().stringValue.lowercased() // this part of the coding path is structurally guaranteed to be a status code diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index b340f8557..2967daee7 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -1173,11 +1173,13 @@ extension JSONSchema.IntegerContext: Decodable { let value = try intAttempt ?? doubleAttempt.map { floatVal in guard let integer = Int(exactly: floatVal) else { + let key = max ? CodingKeys.maximum : CodingKeys.minimum + let subject = key.rawValue throw GenericError( - subjectName: max ? "maximum" : "minimum", + subjectName: subject, details: "Expected an Integer literal but found a floating point value (\(String(describing: floatVal)))", - codingPath: decoder.codingPath, - pathIncludesSubject: false + codingPath: decoder.codingPath + [key], + pathIncludesSubject: true ) } return integer diff --git a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift index a8f913f35..572efec6f 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift @@ -44,7 +44,8 @@ final class SchemaErrorTests: XCTestCase { "200", "content", "application/json", - "schema" + "schema", + "maximum" ]) } } From f6d68c7531451ea238a1be0125959e7b026e29dd Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 18 Dec 2025 09:05:17 -0600 Subject: [PATCH 68/73] fix vendor extension error --- Sources/OpenAPIKit/CodableVendorExtendable.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/CodableVendorExtendable.swift b/Sources/OpenAPIKit/CodableVendorExtendable.swift index 34c151bbf..e17811231 100644 --- a/Sources/OpenAPIKit/CodableVendorExtendable.swift +++ b/Sources/OpenAPIKit/CodableVendorExtendable.swift @@ -114,7 +114,8 @@ extension CodableVendorExtendable { throw GenericError( subjectName: "Vendor Extension", details: "Found at least one vendor extension property that does not begin with the required 'x-' prefix. Invalid properties: \(invalidKeysList)", - codingPath: decoder.codingPath + codingPath: decoder.codingPath, + pathIncludesSubject: false ) } From b4bce37b177ae035d1292d490d75a7009c8e4c45 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 18 Dec 2025 09:24:04 -0600 Subject: [PATCH 69/73] update migration guide --- .../migration_guides/v5_migration_guide.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 57ae7732f..8c2ced69d 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -297,6 +297,52 @@ case .b(let positionalEncoding): } ``` +### Content Map (`OpenAPI.Content.Map`) +Content maps now reflect the specification correctly in allowing references to +Media Type Objects (`OpenAPI.Content`). This means that code that creates or +reads `OpenAPI.Content` values from a `Content.Map` needs to be updated to dig +one level deeper and account for the possibility that the `Content` is +referenced to instead of inlined. + +```swift +// BEFORE +let contentMap: OpenAPI.Content.Map = [ + .json: .init(schema: .string) +] +let jsonContent: OpenAPI.Content? = contentMap[.json] + +// AFTER +let contentMap: OpenAPI.Content.Map = [ + .json: .content(.init(schema: .string)), + .xml: .component(named: "xml") +] +let jsonContent: OpenAPI.Content? = contentMap[.json]?.contentValue +if case let .b(jsonContent) = contentMap[.json] { /*...*/ } + +let referenceToContent: OpenAPI.Reference? = contentMap[.xml]?.reference +if case let .a(referenceToContent) = contentMap[.xml] { /*...*/ } + +``` + +If you are constructing an `OpenAPI.Content.Map`, you have one other option for +migrating existing code: You can use the new `.direct()` convenience +constructor: +```swift +// BEFORE +let contentMap: OpenAPI.Content.Map = [ + .json: .init(schema: .string) +] + +// AFTER +let contentMap: OpenAPI.Content.Map = [ + .json: .content(.init(schema: .string)) +] +// OR +let contentMap: OpenAPI.Content.Map = .direct([ + .json: .init(schema: .string) +]) +``` + ### Security Scheme Object (`OpenAPI.SecurityScheme`) The `type` property's enumeration gains a new associated value on the `oauth2` case. From fbb44098ab2ba41efbc27ca0f84c5b5f8e7c991e Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 18 Dec 2025 11:33:02 -0600 Subject: [PATCH 70/73] move example value to new type, add support for OAS 3.2.0 example fields --- Sources/OpenAPIKit/Content/Content.swift | 20 +- .../Content/DereferencedContent.swift | 2 +- Sources/OpenAPIKit/Example.swift | 219 ----------- Sources/OpenAPIKit/Example/Example.swift | 368 ++++++++++++++++++ Sources/OpenAPIKit/Example/ExampleValue.swift | 88 +++++ .../Parameter/DereferencedSchemaContext.swift | 2 +- .../Parameter/ParameterSchemaContext.swift | 10 +- Sources/OpenAPIKitCompat/Compat30To31.swift | 10 +- .../DocumentConversionTests.swift | 9 +- Tests/OpenAPIKitTests/ComponentsTests.swift | 12 +- .../Content/ContentTests.swift | 36 +- .../Content/DereferencedContentTests.swift | 12 +- Tests/OpenAPIKitTests/ExampleTests.swift | 106 +++-- .../OpenAPIReferenceTests.swift | 2 +- .../DereferencedSchemaContextTests.swift | 12 +- .../Parameter/ParameterSchemaTests.swift | 26 +- .../Parameter/ParameterTests.swift | 4 +- .../Validator/BuiltinValidationTests.swift | 2 +- 18 files changed, 622 insertions(+), 318 deletions(-) delete mode 100644 Sources/OpenAPIKit/Example.swift create mode 100644 Sources/OpenAPIKit/Example/Example.swift create mode 100644 Sources/OpenAPIKit/Example/ExampleValue.swift diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index ac87d8f1b..51a32fb92 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -192,7 +192,7 @@ extension OpenAPI { self.schema = schemaValue } self.examples = examples - self.example = examples.flatMap(Self.firstExample(from:)) + self.example = examples.flatMap(Self.firstExampleValue(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions @@ -209,7 +209,7 @@ extension OpenAPI { ) { self.schema = .reference(schemaReference.jsonReference) self.examples = examples - self.example = examples.flatMap(Self.firstExample(from:)) + self.example = examples.flatMap(Self.firstExampleValue(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions @@ -228,7 +228,7 @@ extension OpenAPI { self.schema = schema self.itemSchema = itemSchema self.examples = examples - self.example = examples.flatMap(Self.firstExample(from:)) + self.example = examples.flatMap(Self.firstExampleValue(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions @@ -246,7 +246,7 @@ extension OpenAPI { self.schema = nil self.itemSchema = itemSchema self.examples = examples - self.example = examples.flatMap(Self.firstExample(from:)) + self.example = examples.flatMap(Self.firstExampleValue(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions @@ -265,7 +265,7 @@ extension OpenAPI { self.schema = nil self.itemSchema = itemSchema self.examples = examples - self.example = examples.flatMap(Self.firstExample(from:)) + self.example = examples.flatMap(Self.firstExampleValue(from:)) if itemEncoding != nil || prefixEncoding != [] { self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) } else { @@ -336,19 +336,19 @@ extension OpenAPI.Content { /// /// Operates on a dictionary with values that may be either /// an Example or a reference to and example. - internal static func firstExample(from exampleDict: OpenAPI.Example.Map) -> AnyCodable? { + internal static func firstExampleValue(from exampleDict: OpenAPI.Example.Map) -> AnyCodable? { return exampleDict .lazy - .compactMap { $0.value.exampleValue?.value?.codableValue } + .compactMap { (_, exampleOrRef) in exampleOrRef.exampleValue?.value?.value } .first } /// Pulls the first example found in the example dictionary /// given. - internal static func firstExample(from exampleDict: OrderedDictionary) -> AnyCodable? { + internal static func firstExampleValue(from exampleDict: OrderedDictionary) -> AnyCodable? { return exampleDict .lazy - .compactMap { $0.value.value?.codableValue } + .compactMap { (_, example) in example.value?.value } .first } } @@ -434,7 +434,7 @@ extension OpenAPI.Content: Decodable { } else { let examplesMap = try container.decodeIfPresent(OpenAPI.Example.Map.self, forKey: .examples) examples = examplesMap - example = examplesMap.flatMap(Self.firstExample(from:)) + example = examplesMap.flatMap(Self.firstExampleValue(from:)) } vendorExtensions = try Self.extensions(from: decoder) diff --git a/Sources/OpenAPIKit/Content/DereferencedContent.swift b/Sources/OpenAPIKit/Content/DereferencedContent.swift index 9a0016298..9160b5b35 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContent.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContent.swift @@ -52,7 +52,7 @@ public struct DereferencedContent: Equatable { } self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) ?? content.example switch content.encoding { diff --git a/Sources/OpenAPIKit/Example.swift b/Sources/OpenAPIKit/Example.swift deleted file mode 100644 index ca2906881..000000000 --- a/Sources/OpenAPIKit/Example.swift +++ /dev/null @@ -1,219 +0,0 @@ -// -// Example.swift -// -// -// Created by Mathew Polzin on 10/6/19. -// - -import OpenAPIKitCore -import Foundation - -extension OpenAPI { - /// OpenAPI Spec "Example Object" - /// - /// See [OpenAPI Example Object](https://spec.openapis.org/oas/v3.1.1.html#example-object). - public struct Example: Equatable, CodableVendorExtendable, Sendable { - public let summary: String? - public let description: String? - - /// Represents the OpenAPI `externalValue` as a URL _or_ - /// the OpenAPI `value` as `AnyCodable`. - public let value: Either? - - /// Dictionary of vendor extensions. - /// - /// These should be of the form: - /// `[ "x-extensionKey": ]` - /// where the values are anything codable. - public var vendorExtensions: [String: AnyCodable] - - public init( - summary: String? = nil, - description: String? = nil, - value: Either? = nil, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.summary = summary - self.description = description - self.value = value - self.vendorExtensions = vendorExtensions - } - } -} - -extension OpenAPI.Example { - public typealias Map = OrderedDictionary, OpenAPI.Example>> -} - -// MARK: - Either Convenience -extension Either where A == OpenAPI.Reference, B == OpenAPI.Example { - /// Construct an `Example`. - public static func example( - summary: String? = nil, - description: String? = nil, - value: Either? = nil, - vendorExtensions: [String: AnyCodable] = [:] - ) -> Self { - return .b( - .init( - summary: summary, - description: description, - value: value, - vendorExtensions: vendorExtensions - ) - ) - } -} - -// MARK: - Describable & Summarizable - -extension OpenAPI.Example : OpenAPISummarizable { - public func overriddenNonNil(summary: String?) -> OpenAPI.Example { - guard let summary = summary else { return self } - return OpenAPI.Example( - summary: summary, - description: description, - value: value, - vendorExtensions: vendorExtensions - ) - } - - public func overriddenNonNil(description: String?) -> OpenAPI.Example { - guard let description = description else { return self } - return OpenAPI.Example( - summary: summary, - description: description, - value: value, - vendorExtensions: vendorExtensions - ) - } -} - -// MARK: - Codable -extension OpenAPI.Example: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encodeIfPresent(summary, forKey: .summary) - try container.encodeIfPresent(description, forKey: .description) - - switch value { - case .a(let url): - try container.encode(url.absoluteURL, forKey: .externalValue) - case .b(let example): - try container.encode(example, forKey: .value) - case nil: - break - } - - if VendorExtensionsConfiguration.isEnabled(for: encoder) { - try encodeExtensions(to: &container) - } - } -} - -extension OpenAPI.Example: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - guard !(container.contains(.externalValue) && container.contains(.value)) else { - throw GenericError( - subjectName: "example value", - details: "Found both `value` and `externalValue` keys in an Example. You must specify one or the other.", - codingPath: container.codingPath - ) - } - - if let externalValue = try container.decodeURLAsStringIfPresent(forKey: .externalValue) { - value = .a(externalValue) - } else if let internalValue = try container.decodeIfPresent(AnyCodable.self, forKey: .value) { - value = .b(internalValue) - } else { - value = nil - } - - summary = try container.decodeIfPresent(String.self, forKey: .summary) - description = try container.decodeIfPresent(String.self, forKey: .description) - - vendorExtensions = try Self.extensions(from: decoder) - } -} - -extension OpenAPI.Example { - internal enum CodingKeys: ExtendableCodingKey { - case summary - case description - case value - case externalValue - case extended(String) - - static var allBuiltinKeys: [CodingKeys] { - return [.summary, .description, .value, .externalValue] - } - - static func extendedKey(for value: String) -> CodingKeys { - return .extended(value) - } - - init?(stringValue: String) { - switch stringValue { - case "summary": - self = .summary - case "description": - self = .description - case "value": - self = .value - case "externalValue": - self = .externalValue - default: - self = .extendedKey(for: stringValue) - } - } - - var stringValue: String { - switch self { - case .summary: - return "summary" - case .description: - return "description" - case .value: - return "value" - case .externalValue: - return "externalValue" - case .extended(let key): - return key - } - } - } -} - -// MARK: - LocallyDereferenceable -extension OpenAPI.Example: LocallyDereferenceable { - /// Examples do not contain any references but for convenience - /// they can be "dereferenced" to themselves. - public func _dereferenced( - in components: OpenAPI.Components, - following references: Set, - dereferencedFromComponentNamed name: String? - ) throws -> OpenAPI.Example{ - var vendorExtensions = self.vendorExtensions - if let name { - vendorExtensions[OpenAPI.Components.componentNameExtension] = .init(name) - } - - return .init( - summary: self.summary, - description: self.description, - value: self.value, - vendorExtensions: vendorExtensions - ) - } -} - -extension OpenAPI.Example: ExternallyDereferenceable { - public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - return (self, .init(), []) - } -} - -extension OpenAPI.Example: Validatable {} diff --git a/Sources/OpenAPIKit/Example/Example.swift b/Sources/OpenAPIKit/Example/Example.swift new file mode 100644 index 000000000..094a7ce1a --- /dev/null +++ b/Sources/OpenAPIKit/Example/Example.swift @@ -0,0 +1,368 @@ +// +// Example.swift +// +// +// Created by Mathew Polzin on 10/6/19. +// + +import OpenAPIKitCore +import Foundation + +extension OpenAPI { + /// OpenAPI Spec "Example Object" + /// + /// See [OpenAPI Example Object](https://spec.openapis.org/oas/v3.2.0.html#example-object). + public struct Example: Equatable, CodableVendorExtendable, Sendable { + public let summary: String? + public let description: String? + + /// Stores the OpenAPI `dataValue`, `serializedValue`, `externalValue`, + /// and `value` fields. + public let value: OpenAPI.Example.Value? + + /// Dictionary of vendor extensions. + /// + /// These should be of the form: + /// `[ "x-extensionKey": ]` + /// where the values are anything codable. + public var vendorExtensions: [String: AnyCodable] + + public var dataValue: AnyCodable? { value?.dataValue } + public var serializedValue: String? { value?.serializedValue } + public var externalValue: URL? { value?.externalValue } + + public var legacyValue: AnyCodable? { value?.legacyValue } + public var dataOrLegacyValue: AnyCodable? { value?.value } + + @available(*, deprecated, message: "This initializer populates the deprecated 'value' field, use init(summary:description:dataValue:serializedValue:vendorExtensions:) or init(summary:description:dataValue:externalValue:vendorExtensions:) instead.") + public init( + summary: String? = nil, + description: String? = nil, + value: Either?, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.summary = summary + self.description = description + switch value { + case .a(let url): self.value = .value(data: nil, serialized: .b(url)) + case .b(let value): self.value = .legacy(value) + case nil: self.value = nil + } + self.vendorExtensions = vendorExtensions + } + + public init( + summary: String? = nil, + description: String? = nil, + legacyValue: Either?, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.summary = summary + self.description = description + switch legacyValue { + case .a(let url): self.value = .value(data: nil, serialized: .b(url)) + case .b(let value): self.value = .legacy(value) + case nil: self.value = nil + } + self.vendorExtensions = vendorExtensions + } + + public init( + summary: String? = nil, + description: String? = nil, + value: Value?, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.summary = summary + self.description = description + self.value = value + self.vendorExtensions = vendorExtensions + } + + public init( + summary: String? = nil, + description: String? = nil, + dataValue: AnyCodable? = nil, + serializedValue: String? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.summary = summary + self.description = description + if dataValue != nil || serializedValue != nil { + self.value = .value(data: dataValue, serialized: serializedValue.map(Either.a)) + } else { + self.value = nil + } + self.vendorExtensions = vendorExtensions + } + + public init( + summary: String? = nil, + description: String? = nil, + dataValue: AnyCodable? = nil, + externalValue: URL?, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.summary = summary + self.description = description + if dataValue != nil || externalValue != nil { + self.value = .value(data: dataValue, serialized: externalValue.map(Either.b)) + } else { + self.value = nil + } + self.vendorExtensions = vendorExtensions + } + } +} + +extension OpenAPI.Example { + public typealias Map = OrderedDictionary, OpenAPI.Example>> +} + +// MARK: - Either Convenience +extension Either where A == OpenAPI.Reference, B == OpenAPI.Example { + @available(*, deprecated, message: "This function populates the deprecated 'value' field, use .value(summary:description:dataValue:serializedValue:vendorExtensions:) or .value(summary:description:dataValue:externalValue:vendorExtensions:) instead.") + public static func example( + summary: String? = nil, + description: String? = nil, + value: Either?, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + return .b( + .init( + summary: summary, + description: description, + legacyValue: value, + vendorExtensions: vendorExtensions + ) + ) + } + + public static func example( + summary: String? = nil, + description: String? = nil, + dataValue: AnyCodable? = nil, + serializedValue: String? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + return .b( + .init( + summary: summary, + description: description, + dataValue: dataValue, + serializedValue: serializedValue, + vendorExtensions: vendorExtensions + ) + ) + } + + public static func example( + summary: String? = nil, + description: String? = nil, + dataValue: AnyCodable? = nil, + externalValue: URL?, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + return .b( + .init( + summary: summary, + description: description, + dataValue: dataValue, + externalValue: externalValue, + vendorExtensions: vendorExtensions + ) + ) + } +} + +// MARK: - Describable & Summarizable + +extension OpenAPI.Example : OpenAPISummarizable { + public func overriddenNonNil(summary: String?) -> OpenAPI.Example { + guard let summary = summary else { return self } + return OpenAPI.Example( + summary: summary, + description: description, + value: value, + vendorExtensions: vendorExtensions + ) + } + + public func overriddenNonNil(description: String?) -> OpenAPI.Example { + guard let description = description else { return self } + return OpenAPI.Example( + summary: summary, + description: description, + value: value, + vendorExtensions: vendorExtensions + ) + } +} + +// MARK: - Codable +extension OpenAPI.Example: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) + + switch value { + case .legacy(let value): + try container.encode(value, forKey: .value) + case .value(data: let dataValue, serialized: let serialized): + try container.encodeIfPresent(dataValue, forKey: .dataValue) + switch serialized { + case .a(let serializedValue): + try container.encode(serializedValue, forKey: .serializedValue) + case .b(let externalValue): + try container.encode(externalValue.absoluteURL, forKey: .externalValue) + case nil: + break + } + case nil: + break; + } + + if VendorExtensionsConfiguration.isEnabled(for: encoder) { + try encodeExtensions(to: &container) + } + } +} + +extension OpenAPI.Example: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let value = try container.decodeIfPresent(AnyCodable.self, forKey: .value) { + guard !(container.contains(.externalValue) || container.contains(.dataValue) || container.contains(.serializedValue)) else { + throw GenericError( + subjectName: "example value", + details: "Found both `value` and one of `externalValue`, `dataValue`, or `serializedValue` keys in an Example. `value` cannot be used with these other keys.", + codingPath: container.codingPath + ) + } + + self.value = .legacy(value) + } else { + + let dataValue = try container.decodeIfPresent(AnyCodable.self, forKey: .dataValue) + if let externalValue = try container.decodeURLAsStringIfPresent(forKey: .externalValue) { + guard !(container.contains(.serializedValue)) else { + throw GenericError( + subjectName: "example value", + details: "Found both `externalValue` and `serializedValue` keys in an Example. These fields are mutually exclusive.", + codingPath: container.codingPath + ) + } + + self.value = .value(data: dataValue, serialized: .b(externalValue)) + } else if let serializedValue = try container.decodeIfPresent(String.self, forKey: .serializedValue) { + self.value = .value(data: dataValue, serialized: .a(serializedValue)) + } else { + self.value = dataValue.map { .value(data: $0, serialized: nil) } + } + } + + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) + + vendorExtensions = try Self.extensions(from: decoder) + } +} + +extension OpenAPI.Example { + internal enum CodingKeys: ExtendableCodingKey { + case summary + case description + case dataValue + case serializedValue + case value + case externalValue + case extended(String) + + static var allBuiltinKeys: [CodingKeys] { + return [ + .summary, + .description, + .dataValue, + .serializedValue, + .value, + .externalValue + ] + } + + static func extendedKey(for value: String) -> CodingKeys { + return .extended(value) + } + + init?(stringValue: String) { + switch stringValue { + case "summary": + self = .summary + case "description": + self = .description + case "dataValue": + self = .dataValue + case "serializedValue": + self = .serializedValue + case "value": + self = .value + case "externalValue": + self = .externalValue + default: + self = .extendedKey(for: stringValue) + } + } + + var stringValue: String { + switch self { + case .summary: + return "summary" + case .description: + return "description" + case .dataValue: + return "dataValue" + case .serializedValue: + return "serializedValue" + case .value: + return "value" + case .externalValue: + return "externalValue" + case .extended(let key): + return key + } + } + } +} + +// MARK: - LocallyDereferenceable +extension OpenAPI.Example: LocallyDereferenceable { + /// Examples do not contain any references but for convenience + /// they can be "dereferenced" to themselves. + public func _dereferenced( + in components: OpenAPI.Components, + following references: Set, + dereferencedFromComponentNamed name: String? + ) throws -> OpenAPI.Example{ + var vendorExtensions = self.vendorExtensions + if let name { + vendorExtensions[OpenAPI.Components.componentNameExtension] = .init(name) + } + + return .init( + summary: self.summary, + description: self.description, + value: self.value, + vendorExtensions: vendorExtensions + ) + } +} + +extension OpenAPI.Example: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { + return (self, .init(), []) + } +} + +extension OpenAPI.Example: Validatable {} diff --git a/Sources/OpenAPIKit/Example/ExampleValue.swift b/Sources/OpenAPIKit/Example/ExampleValue.swift new file mode 100644 index 000000000..f8dae0dad --- /dev/null +++ b/Sources/OpenAPIKit/Example/ExampleValue.swift @@ -0,0 +1,88 @@ +// +// ExampleValue.swift +// + +import Foundation + +extension OpenAPI.Example { + /// OpenAPI Spec "Example Object" `datValue`, `serializedValue`, + /// `externalValue`, and `value` fields get represented by this type in + /// order to guard against forbidden combinations of those fields. + /// + /// The `dataValue` and `serializedValue` fields were added in OAS 3.2.0; + /// for OAS 3.1.x documents, use of these fields will produce a warning + /// upon document validation. + /// + /// See [OpenAPI Example Object](https://spec.openapis.org/oas/v3.2.0.html#example-object). + /// + /// The fields can be used in the following combinations: + /// |-------------------------------------------------------------| + /// | dataValue | serializedValue | externalValue | value | + /// |-------------|-------------------|-----------------|---------| + /// | + | | | | + /// | + | + | | | + /// | + | | + | | + /// | | + | | | + /// | | | + | | + /// | | | | + | + /// |-------------------------------------------------------------| + /// + /// **Examples:** + /// + /// // dataValue + serializedValue (xml) + /// Value.value(data: ["name": "Frank"], serialized: .a("Frank")) + /// + /// // dataValue + externalValue + /// Value.value(data: ["name": "Susan"], serialized: .b(URL(string: "https://website.com/examples/name.xml")!)) + /// + /// // externalValue + /// Value.value(data: nil, serialized: .b(URL(string: "https://website.com/examples/name.xml")!)) + /// + /// // value + /// Value.legacy(["name": "Sam"]) + /// + public enum Value: Equatable, Sendable { + case legacy(AnyCodable) + case value(data: AnyCodable?, serialized: Either?) + } +} + +extension OpenAPI.Example.Value { + /// The OpenAPI Spec `value` or `dataValue` if either is specified. If you + /// need to differentiate between the two fields, `switch` on the `Value` + /// instead or use the `legacyValue` or `dataValue` accessors. + public var value: AnyCodable? { + switch self { + case .legacy(let value), .value(data: let .some(value), serialized: _): value + default: nil + } + } + + public var legacyValue: AnyCodable? { + switch self { + case .legacy(let value): value + default: nil + } + } + + public var dataValue: AnyCodable? { + switch self { + case .value(data: let .some(value), serialized: _): value + default: nil + } + } + + public var serializedValue: String? { + switch self { + case .value(data: _, serialized: let .a(value)): value + default: nil + } + } + + public var externalValue: URL? { + switch self { + case .value(data: _, serialized: let .b(value)): value + default: nil + } + } +} diff --git a/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift b/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift index a101c1653..66a4ac06a 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift @@ -47,7 +47,7 @@ public struct DereferencedSchemaContext: Equatable { .mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) ?? schemaContext.example self.underlyingSchemaContext = schemaContext diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index c7b3b02fd..1c0448f3c 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -93,7 +93,7 @@ extension OpenAPI.Parameter { self.allowReserved = allowReserved self.schema = .init(schema) self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) self.conditionalWarnings = style.conditionalWarnings } @@ -106,7 +106,7 @@ extension OpenAPI.Parameter { self.allowReserved = allowReserved self.schema = .init(schema) self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) self.explode = style.defaultExplode @@ -123,7 +123,7 @@ extension OpenAPI.Parameter { self.allowReserved = allowReserved self.schema = .init(schemaReference) self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) self.conditionalWarnings = style.conditionalWarnings } @@ -136,7 +136,7 @@ extension OpenAPI.Parameter { self.allowReserved = allowReserved self.schema = .init(schemaReference) self.examples = examples - self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + self.example = examples.flatMap(OpenAPI.Content.firstExampleValue(from:)) self.explode = style.defaultExplode @@ -321,7 +321,7 @@ extension OpenAPI.Parameter.SchemaContext { } else { let examplesMap = try container.decodeIfPresent(OpenAPI.Example.Map.self, forKey: .examples) examples = examplesMap - example = examplesMap.flatMap(OpenAPI.Content.firstExample(from:)) + example = examplesMap.flatMap(OpenAPI.Content.firstExampleValue(from:)) } self.conditionalWarnings = style.conditionalWarnings diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 91e8b257f..506f70378 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -138,10 +138,16 @@ extension OpenAPIKit30.OpenAPI.Parameter.Context { extension OpenAPIKit30.OpenAPI.Example: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Example { - OpenAPIKit.OpenAPI.Example( + let newValue: OpenAPIKit.OpenAPI.Example.Value? = value.map { value in + switch value { + case .a(let url): .value(data: nil, serialized: .b(url)) + case .b(let data): .legacy(data) + } + } + return OpenAPIKit.OpenAPI.Example( summary: summary, description: description, - value: value, + value: newValue, vendorExtensions: vendorExtensions ) } diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 01b7fd6ae..896531e5a 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1237,8 +1237,15 @@ fileprivate func assertEqualNewToOld(_ newStringContext: OpenAPIKit.JSONSchema.S fileprivate func assertEqualNewToOld(_ newExample: OpenAPIKit.OpenAPI.Example, _ oldExample: OpenAPIKit30.OpenAPI.Example) { XCTAssertEqual(newExample.summary, oldExample.summary) XCTAssertEqual(newExample.description, oldExample.description) - XCTAssertEqual(newExample.value, oldExample.value) XCTAssertEqual(newExample.vendorExtensions, oldExample.vendorExtensions) + + XCTAssertNil(newExample.serializedValue) + XCTAssertNil(newExample.dataValue) + switch oldExample.value { + case .a(let url): XCTAssertEqual(url, newExample.externalValue) + case .b(let data): XCTAssertEqual(data, newExample.value?.value) + case nil: XCTAssertNil(newExample.value) + } } fileprivate func assertEqualNewToOld(_ newEncoding: OpenAPIKit.OpenAPI.Content.Encoding, _ oldEncoding: OpenAPIKit30.OpenAPI.Content.Encoding) throws { diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 31ae5e77e..1aaa86cdd 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -38,7 +38,7 @@ final class ComponentsTests: XCTestCase { "three": .parameter(.init(name: "hi", context: .query(content: [:]))) ], examples: [ - "four": .example(.init(value: .init(URL(string: "http://address.com")!))) + "four": .example(.init(legacyValue: .init(URL(string: "http://address.com")!))) ], requestBodies: [ "five": .request(.init(content: [:])) @@ -88,7 +88,7 @@ final class ComponentsTests: XCTestCase { "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ - "four": .init(value: .init(URL(string: "http://address.com")!)) + "four": .init(legacyValue: .init(URL(string: "http://address.com")!)) ], requestBodies: [ "five": .init(content: [:]) @@ -233,7 +233,7 @@ final class ComponentsTests: XCTestCase { "three": .init(name: "hello", context: .query(schema: .string)) ], examples: [ - "four": .init(value: .init(URL(string: "hello.com/hello")!)) + "four": .init(legacyValue: .init(URL(string: "hello.com/hello")!)) ], requestBodies: [ "five": .init(content: [:]) @@ -275,7 +275,7 @@ final class ComponentsTests: XCTestCase { XCTAssertEqual(components[ref1], .string) XCTAssertEqual(components[ref2], .init(description: "hello", content: [:])) XCTAssertEqual(components[ref3], .init(name: "hello", context: .query(schema: .string))) - XCTAssertEqual(components[ref4], .init(value: .init(URL(string: "hello.com/hello")!))) + XCTAssertEqual(components[ref4], .init(legacyValue: .init(URL(string: "hello.com/hello")!))) XCTAssertEqual(components[ref5], .init(content: [:])) XCTAssertEqual(components[ref6], .init(schema: .string)) XCTAssertEqual(components[ref7], .apiKey(name: "hello", location: .cookie)) @@ -484,7 +484,7 @@ extension ComponentsTests { "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ - "four": .init(value: .init(URL(string: "http://address.com")!)) + "four": .init(legacyValue: .init(URL(string: "http://address.com")!)) ], requestBodies: [ "five": .init(content: [:]) @@ -723,7 +723,7 @@ extension ComponentsTests { "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ - "four": .init(value: .init(URL(string: "http://address.com")!)) + "four": .init(legacyValue: .init(URL(string: "http://address.com")!)) ], requestBodies: [ "five": .init(content: [:]) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index e1cdca2ef..d20e2dd97 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -33,32 +33,32 @@ final class ContentTests: XCTestCase { let withExamples = OpenAPI.Content( schema: .init(.string), examples: [ - "hello": .example(.init(value: .init("world"))), - "bbbb": .example(.init(value: .b("pick me"))), - "aaaa": .example(.init(value: .a(URL(string: "http://google.com")!))) + "hello": .example(serializedValue: "world"), + "bbbb": .example(dataValue: .init(["hi": "hello"])), + "aaaa": .example(externalValue: URL(string: "http://google.com")!) ] ) XCTAssertNotNil(withExamples.examples) - // we expect the example to be the first example where ordering - // is the order in which the examples are given: - XCTAssertEqual(withExamples.example?.value as? String, "world") - XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + // we expect the example (singular) to be the first example with a data value + // where ordering is the order in which the examples are given: + XCTAssertEqual(withExamples.example?.value as? [String: String], ["hi": "hello"]) + XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(serializedValue: "world")) XCTAssertEqual(withExamples.conditionalWarnings.count, 0) let withExamples2 = OpenAPI.Content( itemSchema: .string, examples: [ - "hello": .example(.init(value: .init("world"))), - "bbbb": .example(.init(value: .b("pick me"))), - "aaaa": .example(.init(value: .a(URL(string: "http://google.com")!))) + "hello": .example(serializedValue: "world"), + "bbbb": .example(dataValue: .init(["hi": "hello"])), + "aaaa": .example(externalValue: URL(string: "http://google.com")!) ] ) XCTAssertEqual(withExamples2.itemSchema, .string) XCTAssertNotNil(withExamples2.examples) - // we expect the example to be the first example where ordering - // is the order in which the examples are given: - XCTAssertEqual(withExamples2.example?.value as? String, "world") - XCTAssertEqual(withExamples2.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + // we expect the example (singular) to be the first example with a data value + // where ordering is the order in which the examples are given: + XCTAssertEqual(withExamples.example?.value as? [String: String], ["hi": "hello"]) + XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(serializedValue: "world")) XCTAssertEqual(withExamples2.conditionalWarnings.count, 1) let t4 = OpenAPI.Content( @@ -412,7 +412,7 @@ extension ContentTests { func test_examplesAndSchemaContent_encode() { let content = OpenAPI.Content(schema: .init(.object(properties: ["hello": .string])), - examples: ["hello": .b(OpenAPI.Example(value: .init(.init([ "hello": "world" ]))))]) + examples: ["hello": .b(OpenAPI.Example(dataValue: .init([ "hello": "world" ])))]) let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) assertJSONEquivalent( @@ -421,7 +421,7 @@ extension ContentTests { { "examples" : { "hello" : { - "value" : { + "dataValue" : { "hello" : "world" } } @@ -448,7 +448,7 @@ extension ContentTests { { "examples" : { "hello": { - "value": { + "dataValue": { "hello" : "world" } } @@ -471,7 +471,7 @@ extension ContentTests { XCTAssertEqual(content.schema, .init(.object(properties: ["hello": .string]))) XCTAssertEqual(content.example?.value as? [String: String], [ "hello": "world" ]) - XCTAssertEqual(content.examples?["hello"]?.exampleValue?.value?.codableValue?.value as? [String: String], [ "hello": "world" ]) + XCTAssertEqual(content.examples?["hello"]?.exampleValue?.value?.value?.value as? [String: String], [ "hello": "world" ]) } func test_decodeFailureForBothExampleAndExamples() { diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index 07f6f0a8e..7ae97e8b1 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -17,7 +17,7 @@ final class DereferencedContentTests: XCTestCase { func test_oneExampleReferenced() throws { let components = OpenAPI.Components.direct( - examples: ["test": .init(value: .init("hello world"))] + examples: ["test": .init(legacyValue: .init("hello world"))] ) let t1 = try OpenAPI.Content( schema: .string, @@ -26,15 +26,15 @@ final class DereferencedContentTests: XCTestCase { XCTAssertEqual(t1.example, "hello world") XCTAssertEqual( t1.examples, - ["test": .init(value: .init("hello world"), vendorExtensions: ["x-component-name": "test"])] + ["test": .init(legacyValue: .init("hello world"), vendorExtensions: ["x-component-name": "test"])] ) } func test_multipleExamplesReferenced() throws { let components = OpenAPI.Components.direct( examples: [ - "test1": .init(value: .init("hello world")), - "test2": .init(value: .a(URL(string: "http://website.com")!)) + "test1": .init(legacyValue: .init("hello world")), + "test2": .init(legacyValue: .a(URL(string: "http://website.com")!)) ] ) let t1 = try OpenAPI.Content( @@ -48,8 +48,8 @@ final class DereferencedContentTests: XCTestCase { XCTAssertEqual( t1.examples, [ - "test1": .init(value: .init("hello world"), vendorExtensions: ["x-component-name": "test1"]), - "test2": .init(value: .init(URL(string: "http://website.com")!), vendorExtensions: ["x-component-name": "test2"]) + "test1": .init(legacyValue: .init("hello world"), vendorExtensions: ["x-component-name": "test1"]), + "test2": .init(legacyValue: .init(URL(string: "http://website.com")!), vendorExtensions: ["x-component-name": "test2"]) ] ) } diff --git a/Tests/OpenAPIKitTests/ExampleTests.swift b/Tests/OpenAPIKitTests/ExampleTests.swift index 1550a249c..2b23f9687 100644 --- a/Tests/OpenAPIKitTests/ExampleTests.swift +++ b/Tests/OpenAPIKitTests/ExampleTests.swift @@ -14,31 +14,47 @@ final class ExampleTests: XCTestCase { let full1 = OpenAPI.Example( summary: "hello", description: "world", - value: .init(URL(string: "https://google.com")!), + legacyValue: .b(.init(URL(string: "https://google.com")!)), vendorExtensions: ["hello": "world"] ) XCTAssertEqual(full1.summary, "hello") XCTAssertEqual(full1.description, "world") - XCTAssertEqual(full1.value, .init(URL(string: "https://google.com")!)) + XCTAssertEqual(full1.value?.value, .init(URL(string: "https://google.com")!)) + XCTAssertEqual(full1.legacyValue, .init(URL(string: "https://google.com")!)) + XCTAssertEqual(full1.dataOrLegacyValue, .init(URL(string: "https://google.com")!)) XCTAssertEqual(full1.vendorExtensions["hello"]?.value as? String, "world") let full2 = OpenAPI.Example( summary: "hello", description: "world", - value: .init("hello"), + dataValue: .init("hello"), vendorExtensions: ["hello": "world"] ) + let full3 = OpenAPI.Example( + summary: "hello", + description: "world", + externalValue: URL(string: "https://google.com")!, + vendorExtensions: ["hello": "world"] + ) + + XCTAssertEqual(full3.summary, "hello") + XCTAssertEqual(full3.description, "world") + XCTAssertEqual(full3.externalValue, URL(string: "https://google.com")!) + XCTAssertEqual(full3.vendorExtensions["hello"]?.value as? String, "world") + XCTAssertEqual(full2.summary, "hello") XCTAssertEqual(full2.description, "world") - XCTAssertEqual(full2.value, .init("hello")) + XCTAssertEqual(full2.value?.value, .init("hello")) + XCTAssertEqual(full2.dataValue, .init("hello")) + XCTAssertEqual(full2.dataOrLegacyValue, .init("hello")) XCTAssertEqual(full2.vendorExtensions["hello"]?.value as? String, "world") - let small = OpenAPI.Example(value: .init("hello")) + let small = OpenAPI.Example(serializedValue: "hello") XCTAssertNil(small.summary) XCTAssertNil(small.description) - XCTAssertEqual(small.value, .init("hello")) + XCTAssertEqual(small.serializedValue, "hello") XCTAssertEqual(small.vendorExtensions, [:]) let noValue = OpenAPI.Example() @@ -46,6 +62,9 @@ final class ExampleTests: XCTestCase { XCTAssertNil(noValue.description) XCTAssertNil(noValue.value) XCTAssertEqual(noValue.vendorExtensions, [:]) + + let _ = OpenAPI.Example(legacyValue: .b(.init(["hi": "hello"]))) + let _ = OpenAPI.Example(legacyValue: .b("hello")) } func test_locallyDereferenceable() throws { @@ -53,7 +72,7 @@ final class ExampleTests: XCTestCase { let full1 = OpenAPI.Example( summary: "hello", description: "world", - value: .init(URL(string: "https://google.com")!), + dataValue: .init(URL(string: "https://google.com")!), vendorExtensions: ["hello": "world"] ) XCTAssertEqual(try full1.dereferenced(in: .noComponents), full1) @@ -61,12 +80,12 @@ final class ExampleTests: XCTestCase { let full2 = OpenAPI.Example( summary: "hello", description: "world", - value: .init("hello"), + serializedValue: "hello", vendorExtensions: ["hello": "world"] ) XCTAssertEqual(try full2.dereferenced(in: .noComponents), full2) - let small = OpenAPI.Example(value: .init("hello")) + let small = OpenAPI.Example(legacyValue: .init("hello")) XCTAssertEqual(try small.dereferenced(in: .noComponents), small) let noValue = OpenAPI.Example() @@ -79,7 +98,7 @@ extension ExampleTests { func test_summaryAndExternalExample_encode() throws { let example = OpenAPI.Example( summary: "hello", - value: .init(URL(string: "https://google.com")!) + legacyValue: .init(URL(string: "https://google.com")!) ) let encodedExample = try orderUnstableTestStringFromEncoding(of: example) @@ -106,14 +125,14 @@ extension ExampleTests { let example = try orderUnstableDecode(OpenAPI.Example.self, from: exampleData) XCTAssertEqual(example, OpenAPI.Example(summary: "hello", - value: .init(URL(string: "https://google.com")!))) - XCTAssertEqual(example.value?.urlValue, URL(string: "https://google.com")!) + legacyValue: .init(URL(string: "https://google.com")!))) + XCTAssertEqual(example.externalValue, URL(string: "https://google.com")!) } func test_descriptionAndInternalExample_encode() throws { let example = OpenAPI.Example( description: "hello", - value: .init("world") + serializedValue: "world" ) let encodedExample = try orderUnstableTestStringFromEncoding(of: example) @@ -122,7 +141,7 @@ extension ExampleTests { """ { "description" : "hello", - "value" : "world" + "serializedValue" : "world" } """ ) @@ -133,18 +152,18 @@ extension ExampleTests { """ { "description" : "hello", - "value" : "world" + "serializedValue" : "world" } """.data(using: .utf8)! let example = try orderUnstableDecode(OpenAPI.Example.self, from: exampleData) XCTAssertEqual(example, OpenAPI.Example(description: "hello", - value: .init("world"))) + serializedValue: "world")) } func test_vendorExtensionAndInternalExample_encode() throws { - let example = OpenAPI.Example(value: .init("world"), + let example = OpenAPI.Example(dataValue: .init("world"), vendorExtensions: ["x-hello": 10]) let encodedExample = try orderUnstableTestStringFromEncoding(of: example) @@ -152,7 +171,7 @@ extension ExampleTests { encodedExample, """ { - "value" : "world", + "dataValue" : "world", "x-hello" : 10 } """ @@ -163,26 +182,59 @@ extension ExampleTests { let exampleData = """ { - "value" : "world", + "dataValue" : "world", "x-hello" : 10 } """.data(using: .utf8)! let example = try! orderUnstableDecode(OpenAPI.Example.self, from: exampleData) - XCTAssertEqual(example, OpenAPI.Example(value: .init("world"), + XCTAssertEqual(example, OpenAPI.Example(dataValue: .init("world"), vendorExtensions: ["x-hello": 10])) } + func test_internalLegacyExample_encode() { + let example = OpenAPI.Example(legacyValue: .b(.init(["hi": "world"]))) + let encodedExample = try! orderUnstableTestStringFromEncoding(of: example) + + assertJSONEquivalent( + encodedExample, + """ + { + "value" : { + "hi" : "world" + } + } + """ + ) + } + + func test_internalLegacyExample_decode() throws { + let exampleData = + """ + { + "value" : { + "hi" : "world" + } + } + """.data(using: .utf8)! + + let example = try orderUnstableDecode(OpenAPI.Example.self, from: exampleData) + + XCTAssertEqual(example, OpenAPI.Example(legacyValue: .b(.init(["hi": "world"])))) + } + func test_internalExample_encode() { - let example = OpenAPI.Example(value: .init("world")) + let example = OpenAPI.Example(dataValue: .init(["hi": "world"])) let encodedExample = try! orderUnstableTestStringFromEncoding(of: example) assertJSONEquivalent( encodedExample, """ { - "value" : "world" + "dataValue" : { + "hi" : "world" + } } """ ) @@ -192,17 +244,19 @@ extension ExampleTests { let exampleData = """ { - "value" : "world" + "dataValue" : { + "hi" : "world" + } } """.data(using: .utf8)! let example = try orderUnstableDecode(OpenAPI.Example.self, from: exampleData) - XCTAssertEqual(example, OpenAPI.Example(value: .init("world"))) + XCTAssertEqual(example, OpenAPI.Example(dataValue: .init(["hi": "world"]))) } func test_externalExample_encode() throws { - let example = OpenAPI.Example(value: .init(URL(string: "https://google.com")!)) + let example = OpenAPI.Example(externalValue: URL(string: "https://google.com")!) let encodedExample = try orderUnstableTestStringFromEncoding(of: example) assertJSONEquivalent( @@ -225,7 +279,7 @@ extension ExampleTests { let example = try orderUnstableDecode(OpenAPI.Example.self, from: exampleData) - XCTAssertEqual(example, OpenAPI.Example(value: .init(URL(string: "https://google.com")!))) + XCTAssertEqual(example, OpenAPI.Example(externalValue: URL(string: "https://google.com")!)) } func test_noExample_encode() throws { diff --git a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift index 63110fe53..7e00d6995 100644 --- a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift +++ b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift @@ -87,7 +87,7 @@ final class OpenAPIReferenceTests: XCTestCase { "hello": .path(name: "name", content: [:], description: "description") ], examples: [ - "hello": .init(summary: "summary", description: "description", value: .b("")) + "hello": .init(summary: "summary", description: "description", legacyValue: .b("")) ], requestBodies: [ "hello": .init(description: "description", content: [:]) diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift index f7e07a8c0..a2708e27d 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift @@ -23,7 +23,7 @@ final class DereferencedSchemaContextTests: XCTestCase { func test_oneExampleReferenced() throws { let components = OpenAPI.Components.direct( - examples: ["test": .init(value: .init("hello world"))] + examples: ["test": .init(legacyValue: .init("hello world"))] ) let t1 = try OpenAPI.Parameter.SchemaContext( .string, @@ -33,15 +33,15 @@ final class DereferencedSchemaContextTests: XCTestCase { XCTAssertEqual(t1.example, "hello world") XCTAssertEqual( t1.examples, - ["test": .init(value: .init("hello world"), vendorExtensions: ["x-component-name": "test"])] + ["test": .init(legacyValue: .init("hello world"), vendorExtensions: ["x-component-name": "test"])] ) } func test_multipleExamplesReferenced() throws { let components = OpenAPI.Components.direct( examples: [ - "test1": .init(value: .init("hello world")), - "test2": .init(value: .a(URL(string: "http://website.com")!)) + "test1": .init(legacyValue: .init("hello world")), + "test2": .init(legacyValue: .a(URL(string: "http://website.com")!)) ] ) let t1 = try OpenAPI.Parameter.SchemaContext( @@ -56,8 +56,8 @@ final class DereferencedSchemaContextTests: XCTestCase { XCTAssertEqual( t1.examples, [ - "test1": .init(value: .init("hello world"), vendorExtensions: ["x-component-name": "test1"]), - "test2": .init(value: .init(URL(string: "http://website.com")!), vendorExtensions: ["x-component-name": "test2"]) + "test1": .init(legacyValue: .init("hello world"), vendorExtensions: ["x-component-name": "test1"]), + "test2": .init(legacyValue: .init(URL(string: "http://website.com")!), vendorExtensions: ["x-component-name": "test2"]) ] ) } diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift index 0ac00d6f0..b095bd1d6 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift @@ -100,8 +100,8 @@ final class ParameterSchemaTests: XCTestCase { .string, style: .deepObject, examples: [ - "one": .example(value: .init("hello")), - "two": .example(value: .init("world")) + "one": .example(.init(legacyValue: .init("hello"))), + "two": .example(.init(legacyValue: .init("world"))) ] ) @@ -112,7 +112,7 @@ final class ParameterSchemaTests: XCTestCase { XCTAssertNotNil(t7.example) XCTAssertEqual(t7.example?.value as? String, "hello") XCTAssertNotNil(t7.examples) - XCTAssertEqual(t7.examples?["two"]?.exampleValue?.value?.codableValue?.value as? String, "world") + XCTAssertEqual(t7.examples?["two"]?.exampleValue?.value?.value?.value as? String, "world") // straight to schema override explode multiple examples let t8 = Schema( @@ -120,8 +120,8 @@ final class ParameterSchemaTests: XCTestCase { style: .deepObject, explode: true, examples: [ - "one": .example(value: .init("hello")), - "two": .example(value: .init("world")) + "one": .example(serializedValue: "hello"), + "two": .example(dataValue: "world") ] ) @@ -130,16 +130,16 @@ final class ParameterSchemaTests: XCTestCase { XCTAssertTrue(t8.explode) XCTAssertFalse(t8.allowReserved) XCTAssertNotNil(t8.example) - XCTAssertEqual(t8.example?.value as? String, "hello") + XCTAssertEqual(t8.example?.value as? String, "world") XCTAssertNotNil(t8.examples) - XCTAssertEqual(t8.examples?["two"]?.exampleValue?.value?.codableValue?.value as? String, "world") + XCTAssertEqual(t8.examples?["two"]?.exampleValue?.value?.value?.value as? String, "world") // schema reference multiple examples let t9 = Schema( schemaReference: .external(URL(string: "hello.yml")!), style: .deepObject, examples: [ - "one": .example(value: .init("hello")), + "one": .example(.init(legacyValue: .init("hello"))), "two": .reference(.external(URL(string: "world.yml")!)) ] ) @@ -159,7 +159,7 @@ final class ParameterSchemaTests: XCTestCase { style: .deepObject, explode: true, examples: [ - "one": .example(value: .init("hello")), + "one": .example(dataValue: .init("hello")), "two": .reference(.external(URL(string: "world.yml")!)) ] ) @@ -322,7 +322,7 @@ extension ParameterSchemaTests { .string, style: .default(for: .path), examples: [ - "one": .example(value: .init("hello")) + "one": .example(dataValue: .init("hello")) ] ) @@ -336,7 +336,7 @@ extension ParameterSchemaTests { "schema" : { "examples" : { "one" : { - "value" : "hello" + "dataValue" : "hello" } }, "schema" : { @@ -356,7 +356,7 @@ extension ParameterSchemaTests { "schema" : { "examples" : { "one" : { - "value" : "hello" + "dataValue" : "hello" } }, "schema" : { @@ -374,7 +374,7 @@ extension ParameterSchemaTests { .string, style: .default(for: .path), examples: [ - "one": .example(value: .init("hello")) + "one": .example(dataValue: .init("hello")) ] ) ) diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift index e2eb0c66d..fcff9dcbf 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift @@ -723,7 +723,7 @@ extension ParameterTests { style: .default(for: .header), allowReserved: true, examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) + "test": .example(externalValue: URL(string: "http://website.com")!) ] ) ) @@ -785,7 +785,7 @@ extension ParameterTests { style: .default(for: .header), allowReserved: true, examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) + "test": .example(externalValue: URL(string: "http://website.com")!) ] ) ) diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 72c446a63..6eae74f86 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -854,7 +854,7 @@ final class BuiltinValidationTests: XCTestCase { "parameter1": .init(name: "test", context: .header(schema: .string)) ], examples: [ - "example1": .init(value: .b("hello")) + "example1": .init(legacyValue: .b("hello")) ], requestBodies: [ "request1": .init(content: [.json: .content(.init(schema: .object))]) From da45fa7473ce1331400b5eaf497740f781b55cb5 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 19 Dec 2025 10:34:38 -0600 Subject: [PATCH 71/73] update migration guide --- .../migration_guides/v5_migration_guide.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 8c2ced69d..bffd78ecc 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -350,6 +350,75 @@ case. Existing code that switches on that property will need to be updated to match on `oauth2(flows: OAuthFlows, metadataUrl: URL?)` now. +### Example Object (`OpenAPI.Example`) +There are no breaking changes to the `OpenAPIKit30` module in this section. + +OAS 3.2.0 introduced new fields along with restrictions around what combinations +of those fields can be used together. This warranted a restructuring of the +OpenAPIKit types to represent all new and existing restrictions together. + +The old `OpenAPI.Example` `value` property was an `Either` to represent either an +inline value or an external value. The new `value` property is of the +`OpenAPI.Example.Value` type. + +If you accessed the `codableValue` of the `OpenAPI.Example` `value` property +before, now you access the `legacyValue` or `dataOrLegacyValue` of the `OpenAPI.Example`: +```swift +// BEFORE +let codableValue = example.value?.codableValue + +// AFTER +let codableValue = example.legacyValue + +// AFTER (a bit more future-proof) +let codableValue = example.dataOrLegacyValue + +// AFTER (if you've migrated to only using `dataValue`) +let codableValue = example.dataValue +``` + +If you accessed the `urlValue` of the `OpenAPI.Example` `value` property +before, now you access the `externalValue` of the `openAPI.Example`: +```swift +// BEFORE +let urlValue = example.value?.urlValue + +// AFTER +let urlValue = example.externalValue +``` + +#### Initializers +The `Example.init(summary:description:value:vendorExtensions:)` initializer has +been deprecated. Your code should either use the new +`Example.init(summary:description:legacyValue:vendorExtensions:)` initializer +(direct replacement) or switch to one of two new initializers that adopts the +OAS 3.2.0 recommendations for semantics dependening on whether the example value +in question represents the structure of the example, the serialized form of +the example, or an external reference to the example: +```swift +// BEFORE (structural example) +let example = OpenAPI.Example(value: .b(.init(["hi": "hello"]))) + +// AFTER (structural example) +let example = OpenAPI.Example(dataValue: .init(["hi": "hello"])) + +// BEFORE (serialized example, xml) +let example = OpenAPI.Example(value: .b("hello")) + +// AFTER (serialized example, xml) +let example = OpenAPI.Example(serialzedValue: "hello") + +// BEFORE (external value) +let example = OpenAPI.Example( + value: .a(URL(string: "https://example.com/example.json")!) +) + +// AFTER (external value) +let example = OpenAPI.Example( + externalValue: URL(string: "https://example.com/example.json")! +) +``` + ### 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 From 7799b106d70f649be45b37357c57cb0b798a8da9 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 20 Dec 2025 10:26:49 -0600 Subject: [PATCH 72/73] add example object conditional warnings --- Sources/OpenAPIKit/Example/Example.swift | 43 +++++++++++++++++++++++- Tests/OpenAPIKitTests/ExampleTests.swift | 26 ++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Sources/OpenAPIKit/Example/Example.swift b/Sources/OpenAPIKit/Example/Example.swift index 094a7ce1a..d1a123682 100644 --- a/Sources/OpenAPIKit/Example/Example.swift +++ b/Sources/OpenAPIKit/Example/Example.swift @@ -12,7 +12,7 @@ extension OpenAPI { /// OpenAPI Spec "Example Object" /// /// See [OpenAPI Example Object](https://spec.openapis.org/oas/v3.2.0.html#example-object). - public struct Example: Equatable, CodableVendorExtendable, Sendable { + public struct Example: HasConditionalWarnings, CodableVendorExtendable, Sendable { public let summary: String? public let description: String? @@ -27,6 +27,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public var dataValue: AnyCodable? { value?.dataValue } public var serializedValue: String? { value?.serializedValue } public var externalValue: URL? { value?.externalValue } @@ -49,6 +51,8 @@ extension OpenAPI { case nil: self.value = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = self.value?.conditionalWarnings ?? [] } public init( @@ -65,6 +69,8 @@ extension OpenAPI { case nil: self.value = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = self.value?.conditionalWarnings ?? [] } public init( @@ -77,6 +83,8 @@ extension OpenAPI { self.description = description self.value = value self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = self.value?.conditionalWarnings ?? [] } public init( @@ -94,6 +102,8 @@ extension OpenAPI { self.value = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = self.value?.conditionalWarnings ?? [] } public init( @@ -111,14 +121,43 @@ extension OpenAPI { self.value = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = self.value?.conditionalWarnings ?? [] } } } +extension OpenAPI.Example: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.value == rhs.value + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + extension OpenAPI.Example { public typealias Map = OrderedDictionary, OpenAPI.Example>> } +extension OpenAPI.Example.Value { + fileprivate var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { + [ + nonNilVersionWarning(fieldName: "dataValue", value: dataValue, minimumVersion: .v3_2_0), + nonNilVersionWarning(fieldName: "serializedValue", value: serializedValue, minimumVersion: .v3_2_0) + ].compactMap { $0 } + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Example Object \(fieldName) field" + ) + } +} + // MARK: - Either Convenience extension Either where A == OpenAPI.Reference, B == OpenAPI.Example { @available(*, deprecated, message: "This function populates the deprecated 'value' field, use .value(summary:description:dataValue:serializedValue:vendorExtensions:) or .value(summary:description:dataValue:externalValue:vendorExtensions:) instead.") @@ -268,6 +307,8 @@ extension OpenAPI.Example: Decodable { description = try container.decodeIfPresent(String.self, forKey: .description) vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = self.value?.conditionalWarnings ?? [] } } diff --git a/Tests/OpenAPIKitTests/ExampleTests.swift b/Tests/OpenAPIKitTests/ExampleTests.swift index 2b23f9687..407e2a51d 100644 --- a/Tests/OpenAPIKitTests/ExampleTests.swift +++ b/Tests/OpenAPIKitTests/ExampleTests.swift @@ -24,6 +24,7 @@ final class ExampleTests: XCTestCase { XCTAssertEqual(full1.legacyValue, .init(URL(string: "https://google.com")!)) XCTAssertEqual(full1.dataOrLegacyValue, .init(URL(string: "https://google.com")!)) XCTAssertEqual(full1.vendorExtensions["hello"]?.value as? String, "world") + XCTAssertEqual(full1.conditionalWarnings.count, 0) let full2 = OpenAPI.Example( summary: "hello", @@ -31,6 +32,13 @@ final class ExampleTests: XCTestCase { dataValue: .init("hello"), vendorExtensions: ["hello": "world"] ) + XCTAssertEqual(full2.summary, "hello") + XCTAssertEqual(full2.description, "world") + XCTAssertEqual(full2.value?.value, .init("hello")) + XCTAssertEqual(full2.dataValue, .init("hello")) + XCTAssertEqual(full2.dataOrLegacyValue, .init("hello")) + XCTAssertEqual(full2.vendorExtensions["hello"]?.value as? String, "world") + XCTAssertEqual(full2.conditionalWarnings.count, 1) let full3 = OpenAPI.Example( summary: "hello", @@ -43,25 +51,31 @@ final class ExampleTests: XCTestCase { XCTAssertEqual(full3.description, "world") XCTAssertEqual(full3.externalValue, URL(string: "https://google.com")!) XCTAssertEqual(full3.vendorExtensions["hello"]?.value as? String, "world") + XCTAssertEqual(full3.conditionalWarnings.count, 0) - XCTAssertEqual(full2.summary, "hello") - XCTAssertEqual(full2.description, "world") - XCTAssertEqual(full2.value?.value, .init("hello")) - XCTAssertEqual(full2.dataValue, .init("hello")) - XCTAssertEqual(full2.dataOrLegacyValue, .init("hello")) - XCTAssertEqual(full2.vendorExtensions["hello"]?.value as? String, "world") + let dataPlusSerialized = OpenAPI.Example( + summary: "hello", + dataValue: .init("hello"), + serializedValue: "hello" + ) + XCTAssertEqual(dataPlusSerialized.summary, "hello") + XCTAssertEqual(dataPlusSerialized.dataValue, .init("hello")) + XCTAssertEqual(dataPlusSerialized.serializedValue, "hello") + XCTAssertEqual(dataPlusSerialized.conditionalWarnings.count, 2) let small = OpenAPI.Example(serializedValue: "hello") XCTAssertNil(small.summary) XCTAssertNil(small.description) XCTAssertEqual(small.serializedValue, "hello") XCTAssertEqual(small.vendorExtensions, [:]) + XCTAssertEqual(small.conditionalWarnings.count, 1) let noValue = OpenAPI.Example() XCTAssertNil(noValue.summary) XCTAssertNil(noValue.description) XCTAssertNil(noValue.value) XCTAssertEqual(noValue.vendorExtensions, [:]) + XCTAssertEqual(noValue.conditionalWarnings.count, 0) let _ = OpenAPI.Example(legacyValue: .b(.init(["hi": "hello"]))) let _ = OpenAPI.Example(legacyValue: .b("hello")) From 940766e89c3bab16986df39f618d071880f72f7b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 20 Dec 2025 10:05:16 -0600 Subject: [PATCH 73/73] add some missing test coverage in compat suite --- .../DocumentConversionTests.swift | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 896531e5a..071ec6c2f 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1141,13 +1141,12 @@ fileprivate func assertEqualNewToOld(_ newSchema: OpenAPIKit.JSONSchema, _ oldSc case .number(let coreContext, let numericContext): let newNumericContext = try XCTUnwrap(newSchema.numberContext) - // TODO: compare number contexts - // try assertEqualNewToOld(newNumericContext, numericContext) + assertEqualNewToOld(newNumericContext, numericContext) try assertEqualNewToOld(newCoreContext, coreContext) case .integer(let coreContext, let integerContext): let newIntegerContext = try XCTUnwrap(newSchema.integerContext) - // TODO: compare integer contexts + assertEqualNewToOld(newIntegerContext, integerContext) try assertEqualNewToOld(newCoreContext, coreContext) case .string(let coreContext, let stringContext): @@ -1157,12 +1156,12 @@ fileprivate func assertEqualNewToOld(_ newSchema: OpenAPIKit.JSONSchema, _ oldSc case .object(let coreContext, let objectContext): let newObjectContext = try XCTUnwrap(newSchema.objectContext) - // TODO: compare object contexts + try assertEqualNewToOld(newObjectContext, objectContext) try assertEqualNewToOld(newCoreContext, coreContext) case .array(let coreContext, let arrayContext): let newArrayContext = try XCTUnwrap(newSchema.arrayContext) - // TODO: compare array contexts + try assertEqualNewToOld(newArrayContext, arrayContext) try assertEqualNewToOld(newCoreContext, coreContext) case .all(of: let schemas, core: let coreContext): @@ -1234,6 +1233,60 @@ fileprivate func assertEqualNewToOld(_ newStringContext: OpenAPIKit.JSONSchema.S XCTAssertNil(newStringContext.contentMediaType) } +fileprivate func assertEqualNewToOld(_ newNumericContext: OpenAPIKit.JSONSchema.NumericContext, _ oldNumericContext: OpenAPIKit30.JSONSchema.NumericContext) { + XCTAssertEqual(newNumericContext.multipleOf, oldNumericContext.multipleOf) + XCTAssertEqual(newNumericContext.maximum?.value, oldNumericContext.maximum?.value) + XCTAssertEqual(newNumericContext.maximum?.exclusive, oldNumericContext.maximum?.exclusive) + XCTAssertEqual(newNumericContext.minimum?.value, oldNumericContext.minimum?.value) + XCTAssertEqual(newNumericContext.minimum?.exclusive, oldNumericContext.minimum?.exclusive) +} + +fileprivate func assertEqualNewToOld(_ newIntegerContext: OpenAPIKit.JSONSchema.IntegerContext, _ oldIntegerContext: OpenAPIKit30.JSONSchema.IntegerContext) { + XCTAssertEqual(newIntegerContext.multipleOf, oldIntegerContext.multipleOf) + XCTAssertEqual(newIntegerContext.maximum?.value, oldIntegerContext.maximum?.value) + XCTAssertEqual(newIntegerContext.maximum?.exclusive, oldIntegerContext.maximum?.exclusive) + XCTAssertEqual(newIntegerContext.minimum?.value, oldIntegerContext.minimum?.value) + XCTAssertEqual(newIntegerContext.minimum?.exclusive, oldIntegerContext.minimum?.exclusive) +} + +fileprivate func assertEqualNewToOld(_ newArrayContext: OpenAPIKit.JSONSchema.ArrayContext, _ oldArrayContext: OpenAPIKit30.JSONSchema.ArrayContext) throws { + if let newItems = newArrayContext.items { + guard let oldItems = oldArrayContext.items else { + return XCTFail("New array context has items defined but old array context does not") + } + try assertEqualNewToOld(newItems, oldItems) + } else { + XCTAssertNil(oldArrayContext.items) + } + XCTAssertNil(newArrayContext.prefixItems) + XCTAssertEqual(newArrayContext.maxItems, oldArrayContext.maxItems) + XCTAssertEqual(newArrayContext.minItems, oldArrayContext.minItems) + XCTAssertEqual(newArrayContext.uniqueItems, oldArrayContext.uniqueItems) +} + +fileprivate func assertEqualNewToOld(_ newObjectContext: OpenAPIKit.JSONSchema.ObjectContext, _ oldObjectContext: OpenAPIKit30.JSONSchema.ObjectContext) throws { + XCTAssertEqual(newObjectContext.minProperties, oldObjectContext.minProperties) + XCTAssertEqual(newObjectContext.maxProperties, oldObjectContext.maxProperties) + switch (newObjectContext.additionalProperties, oldObjectContext.additionalProperties) { + case (.a(let value1), .a(let value2)): XCTAssertEqual(value1, value2) + case (.b(let value1), .b(let value2)): try assertEqualNewToOld(value1, value2) + case (nil, nil): break + default: + XCTFail("Found mismatch of bool or JSONSchema value for additionalProperties of object contexts: \(newObjectContext) is not equal to \(oldObjectContext)") + } + XCTAssertEqual(newObjectContext.properties.count, oldObjectContext.properties.count) + for (key, prop) in oldObjectContext.properties { + guard let newProp = newObjectContext.properties[key] else { + XCTFail("Found old prop \(prop) at key \(key) with no neq prop set in the OAS 3.1/2 document") + return + } + try assertEqualNewToOld(newProp, prop) + } + XCTAssertEqual(newObjectContext.requiredProperties, oldObjectContext.requiredProperties) + XCTAssertEqual(newObjectContext.optionalProperties, oldObjectContext.optionalProperties) +} + + fileprivate func assertEqualNewToOld(_ newExample: OpenAPIKit.OpenAPI.Example, _ oldExample: OpenAPIKit30.OpenAPI.Example) { XCTAssertEqual(newExample.summary, oldExample.summary) XCTAssertEqual(newExample.description, oldExample.description)