From 74e8bb76a2373d667f0791e66ce3729b6e75d808 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 20 Oct 2025 09:41:15 -0500 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)