From a31904f85cd23c0ac44adf8569a1a9f2f5d12a9c Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 10 Nov 2025 10:21:58 -0600 Subject: [PATCH 1/2] Add response object summary field. make response object description optional --- .../OpenAPIConditionalWarnings.swift | 6 ++ Sources/OpenAPIKit/Response/Response.swift | 79 +++++++++++++++++-- .../ResponseErrorTests.swift | 30 ------- .../Response/ResponseTests.swift | 44 +++++++++++ 4 files changed, 122 insertions(+), 37 deletions(-) diff --git a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift index ed4092da2..6e47cab74 100644 --- a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift +++ b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift @@ -51,5 +51,11 @@ internal extension OpenAPI.Document { return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) } + + static func version(lessThan version: OpenAPI.Document.Version, doesNotAllowOptional subject: String) -> (any Condition, OpenAPI.Warning) { + let warning = OpenAPI.Warning.message("\(subject) cannot be nil for OpenAPI document versions lower than \(version.rawValue)") + + return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) + } } } diff --git a/Sources/OpenAPIKit/Response/Response.swift b/Sources/OpenAPIKit/Response/Response.swift index af5d25365..b03c3ff03 100644 --- a/Sources/OpenAPIKit/Response/Response.swift +++ b/Sources/OpenAPIKit/Response/Response.swift @@ -11,8 +11,10 @@ extension OpenAPI { /// OpenAPI Spec "Response Object" /// /// See [OpenAPI Response Object](https://spec.openapis.org/oas/v3.1.1.html#response-object). - public struct Response: Equatable, CodableVendorExtendable, Sendable { - public var description: String + public struct Response: HasConditionalWarnings, CodableVendorExtendable, Sendable { + public var summary: String? + public var description: String? + public var headers: Header.Map? /// An empty Content map will be omitted from encoding. public var content: Content.Map @@ -26,22 +28,62 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( - description: String, + summary: String? = nil, + description: String? = nil, headers: Header.Map? = nil, content: Content.Map = [:], links: Link.Map = [:], vendorExtensions: [String: AnyCodable] = [:] ) { + self.summary = summary self.description = description self.headers = headers self.content = content self.links = links self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +extension OpenAPI.Response: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.headers == rhs.headers + && lhs.content == rhs.content + && lhs.links == rhs.links + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Response \(fieldName) field" + ) + } +} + +fileprivate func notOptionalVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + guard value == nil else { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotAllowOptional: "The Response \(fieldName) field" + ) +} + extension OpenAPI.Response { public typealias Map = OrderedDictionary, OpenAPI.Response>> } @@ -72,7 +114,8 @@ extension OrderedDictionary where Key == OpenAPI.Response.StatusCode { extension Either where A == OpenAPI.Reference, B == OpenAPI.Response { public static func response( - description: String, + summary: String? = nil, + description: String? = nil, headers: OpenAPI.Header.Map? = nil, content: OpenAPI.Content.Map = [:], links: OpenAPI.Link.Map = [:] @@ -89,19 +132,27 @@ extension Either where A == OpenAPI.Reference, B == OpenAPI.Re } // MARK: - Describable -extension OpenAPI.Response: OpenAPIDescribable { +extension OpenAPI.Response: OpenAPISummarizable { public func overriddenNonNil(description: String?) -> OpenAPI.Response { guard let description = description else { return self } var response = self response.description = description return response } + + public func overriddenNonNil(summary: String?) -> OpenAPI.Response { + guard let summary = summary else { return self } + var response = self + response.summary = summary + return response + } } // MARK: - Codable extension OpenAPI.Response { internal enum CodingKeys: ExtendableCodingKey { + case summary case description case headers case content @@ -110,6 +161,7 @@ extension OpenAPI.Response { static var allBuiltinKeys: [CodingKeys] { return [ + .summary, .description, .headers, .content, @@ -123,6 +175,8 @@ extension OpenAPI.Response { init?(stringValue: String) { switch stringValue { + case "summary": + self = .summary case "description": self = .description case "headers": @@ -138,6 +192,8 @@ extension OpenAPI.Response { var stringValue: String { switch self { + case .summary: + return "summary" case .description: return "description" case .headers: @@ -157,7 +213,8 @@ extension OpenAPI.Response: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(description, forKey: .description) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(headers, forKey: .headers) if !content.isEmpty { @@ -179,13 +236,21 @@ extension OpenAPI.Response: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) do { - description = try container.decode(String.self, forKey: .description) + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers) content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:] links = try container.decodeIfPresent(OpenAPI.Link.Map.self, forKey: .links) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } + } catch let error as GenericError { throw OpenAPI.Error.Decoding.Response(error) diff --git a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift index fba1f7fd3..ff178666a 100644 --- a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift @@ -52,36 +52,6 @@ final class ResponseErrorTests: XCTestCase { } } - func test_missingDescriptionResponseObject() { - let documentYML = - """ - openapi: "3.1.0" - info: - title: test - version: 1.0 - paths: - /hello/world: - get: - responses: - '200': - not-a-thing: hi - """ - - XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in - - let openAPIError = OpenAPI.Error(from: error) - - XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Response in .responses.200 for the **GET** endpoint under `/hello/world`. \n\nResponse could not be decoded because:\nExpected to find `description` key but it is missing..") - XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ - "paths", - "/hello/world", - "get", - "responses", - "200" - ]) - } - } - func test_badResponseExtension() { let documentYML = """ diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index 1f4367ff7..0d85c85ae 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -25,6 +25,28 @@ final class ResponseTests: XCTestCase { XCTAssertEqual(r2.description, "") XCTAssertEqual(r2.headers?["hello"]?.headerValue, header) XCTAssertEqual(r2.content, [.json: content]) + XCTAssertEqual(r2.conditionalWarnings.count, 0) + + // two OAS 3.2.0 warnings: summary is used and description is not + let r3 = OpenAPI.Response(summary: "", + content: [:]) + XCTAssertEqual(r3.summary, "") + XCTAssertNil(r3.description) + XCTAssertEqual(r3.conditionalWarnings.count, 2) + + // one OAS 3.2.0 warnings: summary is used + let r4 = OpenAPI.Response(summary: "", + description: "", + content: [:]) + XCTAssertEqual(r4.summary, "") + XCTAssertEqual(r4.description, "") + XCTAssertEqual(r4.conditionalWarnings.count, 1) + + // one OAS 3.2.0 warnings: description is not used + let r5 = OpenAPI.Response(content: [:]) + XCTAssertNil(r5.summary) + XCTAssertNil(r5.description) + XCTAssertEqual(r5.conditionalWarnings.count, 1) } func test_responseMap() { @@ -122,6 +144,18 @@ extension ResponseTests { } """ ) + + let response3 = OpenAPI.Response(summary: "", content: [:]) + let encodedResponse3 = try! orderUnstableTestStringFromEncoding(of: response3) + + assertJSONEquivalent( + encodedResponse3, + """ + { + "summary" : "" + } + """ + ) } func test_emptyDescriptionEmptyContent_decode() { @@ -157,6 +191,16 @@ extension ResponseTests { let response3 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData3) XCTAssertEqual(response3, OpenAPI.Response(description: "", headers: [:], content: [:])) + + let responseData4 = + """ + { + "summary" : "" + } + """.data(using: .utf8)! + let response4 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData4) + + XCTAssertEqual(response4, OpenAPI.Response(summary: "", content: [:])) } func test_populatedDescriptionPopulatedContent_encode() { From a9a85de699527f5656326081e0fb0ff7daca6c0a Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 11 Nov 2025 08:54:15 -0600 Subject: [PATCH 2/2] update migration guide --- documentation/migration_guides/v5_migration_guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 54eac43e9..0a94ebf12 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -159,6 +159,13 @@ specification) in this section. A new `cookie` style has been added. Code that exhaustively switches on the `OpenAPI.Parameter.SchemaContext.Style` enum will need to be updated. +### Response Objects +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +The Response Object `description` field is not optional so code may need to +change to account for it possibly being `nil`. + ### Errors Some error messages have been tweaked in small ways. If you match on the string descriptions of any OpenAPIKit errors, you may need to update the