diff --git a/README.md b/README.md index 16ffa8797..9d4c810f9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,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`. diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index 14667271b..7abccbd47 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 { + public struct Document: HasWarnings, CodableVendorExtendable { /// 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_0, 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 { + return 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 @@ -380,6 +399,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(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 { @@ -424,7 +467,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 InconsistencyError( + 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) ?? [] @@ -443,6 +505,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 25ac8ba44..ef1a79bbb 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, "Inconsistency encountered when parsing `openapi` in the root Document object: Cannot initialize Version from invalid String value null.") + XCTAssertEqual(openAPIError.localizedDescription, "Inconsistency 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 4ae5f9070..9d36d5e2e 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -520,6 +520,27 @@ extension DocumentTests { ) } + 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 { let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), diff --git a/Tests/OpenAPIKitTests/TestHelpers.swift b/Tests/OpenAPIKitTests/TestHelpers.swift index ec2960f9d..d9224e5e4 100644 --- a/Tests/OpenAPIKitTests/TestHelpers.swift +++ b/Tests/OpenAPIKitTests/TestHelpers.swift @@ -53,8 +53,10 @@ fileprivate let foundationTestDecoder = { () -> JSONDecoder in return decoder }() -func orderUnstableDecode(_ type: T.Type, from data: Data) throws -> T { - return try foundationTestDecoder.decode(T.self, from: data) +func orderUnstableDecode(_ type: T.Type, from data: Data, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T { + let decoder = foundationTestDecoder + decoder.userInfo = userInfo + return try decoder.decode(T.self, from: data) } fileprivate let yamsTestDecoder = { () -> YAMLDecoder in