From c88800244008fc8d2305cad87cd01137cb2e7fce Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 22 Oct 2025 09:53:28 -0500 Subject: [PATCH 1/2] add support for document version decode map --- .../OpenAPIKit/CodableVendorExtendable.swift | 4 +- Sources/OpenAPIKit/Document/Document.swift | 68 ++++++++++++++++++- .../DocumentErrorTests.swift | 2 +- .../Document/DocumentTests.swift | 23 ++++++- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIKit/CodableVendorExtendable.swift b/Sources/OpenAPIKit/CodableVendorExtendable.swift index a04516e5d..34c151bbf 100644 --- a/Sources/OpenAPIKit/CodableVendorExtendable.swift +++ b/Sources/OpenAPIKit/CodableVendorExtendable.swift @@ -29,11 +29,11 @@ public protocol VendorExtendable { public enum VendorExtensionsConfiguration { public static let enabledKey: CodingUserInfoKey = .init(rawValue: "vendor-extensions-enabled")! - static func isEnabled(for decoder: Decoder) -> Bool { + internal static func isEnabled(for decoder: Decoder) -> Bool { decoder.userInfo[enabledKey] as? Bool ?? true } - static func isEnabled(for encoder: Encoder) -> Bool { + internal static func isEnabled(for encoder: Encoder) -> Bool { encoder.userInfo[enabledKey] as? Bool ?? true } } diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index 5126646e8..2b3afa73d 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -45,7 +45,7 @@ extension OpenAPI { /// /// See the documentation on `DereferencedDocument.resolved()` for more. /// - public struct Document: Equatable, CodableVendorExtendable, Sendable { + public struct Document: HasWarnings, CodableVendorExtendable, Sendable { /// OpenAPI Spec "openapi" field. /// /// OpenAPIKit only explicitly supports versions that can be found in @@ -141,6 +141,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let warnings: [Warning] + public init( openAPIVersion: Version = .v3_1_1, info: Info, @@ -163,10 +165,27 @@ extension OpenAPI { self.tags = tags self.externalDocs = externalDocs self.vendorExtensions = vendorExtensions + + self.warnings = [] } } } +extension OpenAPI.Document: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.openAPIVersion == rhs.openAPIVersion + && lhs.info == rhs.info + && lhs.servers == rhs.servers + && lhs.paths == rhs.paths + && lhs.components == rhs.components + && lhs.webhooks == rhs.webhooks + && lhs.security == rhs.security + && lhs.tags == rhs.tags + && lhs.externalDocs == rhs.externalDocs + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + extension OpenAPI.Document { /// Create a new OpenAPI Document with /// all paths not passign the given predicate @@ -470,6 +489,30 @@ extension OpenAPI.Document { } } +/// OpenAPIKit supports some additional Encoder/Decoder configuration above and beyond +/// what the Encoder or Decoder support out of box. +/// +/// To instruct OpenAPIKit to decode OpenAPI Standards versions it does not +/// natively support, set `userInfo[DocumentConfiguration.versionMapKey] = +/// ["3.5.0": OpenAPI.Document.Version.v3_1_1]`. +/// +/// That will cause OpenAPIKit to accept OAS v3.5.0 on decode and treat it as +/// the natively supported v3.1.1. This feature exists to allow OpenAPIKit to +/// be configured to parse future versions of the OAS standard that are +/// determined (by you) to be backwards compatible with a previous version +/// prior to OpenAPIKit gaining official support for the new version and its +/// features. +public enum DocumentConfiguration { + public static let versionMapKey: CodingUserInfoKey = .init(rawValue: "document-version-map")! + + internal static func version(for decoder: Decoder, versionString: String) -> OpenAPI.Document.Version? { + guard let map = decoder.userInfo[versionMapKey] as? [String: OpenAPI.Document.Version] + else { return nil } + + return map[versionString] + } +} + // MARK: - Codable extension OpenAPI.Document: Encodable { @@ -516,7 +559,26 @@ extension OpenAPI.Document: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) do { - openAPIVersion = try container.decode(OpenAPI.Document.Version.self, forKey: .openAPIVersion) + let decodedVersion = try container.decode(String.self, forKey: .openAPIVersion) + + var warnings = [Warning]() + + if let version = OpenAPI.Document.Version(rawValue: decodedVersion) { + openAPIVersion = version + } else if let version = DocumentConfiguration.version(for: decoder, versionString: decodedVersion) { + openAPIVersion = version + + warnings.append(.message( + "Document Version \(decodedVersion) is being decoded as version \(version.rawValue). Not all features of OAS \(decodedVersion) will be supported" + )) + } else { + throw GenericError( + subjectName: OpenAPI.Document.CodingKeys.openAPIVersion.stringValue, + details: "Failed to parse Document Version \(decodedVersion) as one of OpenAPIKit's supported options", + codingPath: container.codingPath + [OpenAPI.Document.CodingKeys.openAPIVersion] + ) + } + info = try container.decode(OpenAPI.Document.Info.self, forKey: .info) servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? [] @@ -535,6 +597,8 @@ extension OpenAPI.Document: Decodable { externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDocumentation.self, forKey: .externalDocs) vendorExtensions = try Self.extensions(from: decoder) + self.warnings = warnings + } catch let error as OpenAPI.Error.Decoding.Path { throw OpenAPI.Error.Decoding.Document(error) diff --git a/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift index 2a5bc2d52..420539a41 100644 --- a/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift @@ -44,7 +44,7 @@ final class DocumentErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `openapi` in the root Document object: Cannot initialize Version from invalid String value null.") + XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `openapi` in the root Document object: Failed to parse Document Version null as one of OpenAPIKit's supported options.") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "openapi" ]) diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 1c65f6c9c..985c9faed 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -585,7 +585,28 @@ extension DocumentTests { } } """.data(using: .utf8)! - XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Document.self, from: documentData)) { error in XCTAssertEqual(OpenAPI.Error(from: error).localizedDescription, "Problem encountered when parsing `openapi` in the root Document object: Cannot initialize Version from invalid String value 3.1.9.") } + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Document.self, from: documentData)) { error in XCTAssertEqual(OpenAPI.Error(from: error).localizedDescription, "Problem encountered when parsing `openapi` in the root Document object: Failed to parse Document Version 3.1.9 as one of OpenAPIKit's supported options.") } + } + + func test_unsupportedButMappedOpenAPIVersion_decode() throws { + let documentData = + """ + { + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.100.100", + "paths" : { + + } + } + """.data(using: .utf8)! + let userInfo = [ + DocumentConfiguration.versionMapKey: ["3.100.100": OpenAPI.Document.Version.v3_1_1] + ] + let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData, userInfo: userInfo) + XCTAssertEqual(document.warnings.map { $0.localizedDescription }, ["Document Version 3.100.100 is being decoded as version 3.1.1. Not all features of OAS 3.100.100 will be supported"]) } func test_specifyServers_encode() throws { From 454e0e91fa41ae20a2b440889e4bb788a3873b35 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 22 Oct 2025 10:00:36 -0500 Subject: [PATCH 2/2] update README --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index a7c95c4fb..368e41296 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,28 @@ let decoder = ... // JSONDecoder() or YAMLDecoder() let openAPIDoc = try decoder.decode(OpenAPI.Document.self, from: ...) ``` +#### Decoding Future Versions +`OpenAPIKit` adds support for new OAS versions when it has support for most or +all of the features of that OAS version. If you want to parse an OpenAPI +Document that is written in a newer version than `OpenAPIKit` supports and you +are asserting that the newer version is possible to parse as if it were the +pre-existing version, you can tell `OpenAPIKit` to parse the newer version as if +it were the older version. + +You do this with `userInfo` passed into the `Decoder` you are using. For +example, to decode a hypothetical document version of `"3.100.100"` as if it +were version `"3.1.1"`, set your decoder up as follows: +```swift +let userInfo = [ + DocumentConfiguration.versionMapKey: ["3.100.100": OpenAPI.Document.Version.v3_1_1] +] + +let decoder = ... // JSONDecoder() or YAMLDecoder() +decoder.userInfo = userInfo + +let openAPIDoc = try decoder.decode(OpenAPI.Document.self, from: ...) +``` + #### Decoding Errors You can wrap any error you get back from a decoder in `OpenAPI.Error` to get a friendlier human-readable description from `localizedDescription`.