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"),