Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
79 changes: 72 additions & 7 deletions Sources/OpenAPIKit/Response/Response.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Subject>(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<Subject>(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<StatusCode, Either<OpenAPI.Reference<OpenAPI.Response>, OpenAPI.Response>>
}
Expand Down Expand Up @@ -72,7 +114,8 @@ extension OrderedDictionary where Key == OpenAPI.Response.StatusCode {
extension Either where A == OpenAPI.Reference<OpenAPI.Response>, 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 = [:]
Expand All @@ -89,19 +132,27 @@ extension Either where A == OpenAPI.Reference<OpenAPI.Response>, 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
Expand All @@ -110,6 +161,7 @@ extension OpenAPI.Response {

static var allBuiltinKeys: [CodingKeys] {
return [
.summary,
.description,
.headers,
.content,
Expand All @@ -123,6 +175,8 @@ extension OpenAPI.Response {

init?(stringValue: String) {
switch stringValue {
case "summary":
self = .summary
case "description":
self = .description
case "headers":
Expand All @@ -138,6 +192,8 @@ extension OpenAPI.Response {

var stringValue: String {
switch self {
case .summary:
return "summary"
case .description:
return "description"
case .headers:
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
30 changes: 0 additions & 30 deletions Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"""
Expand Down
44 changes: 44 additions & 0 deletions Tests/OpenAPIKitTests/Response/ResponseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions documentation/migration_guides/v5_migration_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down