Skip to content

Commit ade52d7

Browse files
authored
Merge pull request #453 from mattpolzin/feature/449/flexible-response-objects
Implement OAS 3.2.0 Response Object tweaks
2 parents 8fbc91b + a9a85de commit ade52d7

File tree

5 files changed

+129
-37
lines changed

5 files changed

+129
-37
lines changed

Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,11 @@ internal extension OpenAPI.Document {
5151

5252
return (DocumentVersionCondition(version: version, comparator: .lessThan), warning)
5353
}
54+
55+
static func version(lessThan version: OpenAPI.Document.Version, doesNotAllowOptional subject: String) -> (any Condition, OpenAPI.Warning) {
56+
let warning = OpenAPI.Warning.message("\(subject) cannot be nil for OpenAPI document versions lower than \(version.rawValue)")
57+
58+
return (DocumentVersionCondition(version: version, comparator: .lessThan), warning)
59+
}
5460
}
5561
}

Sources/OpenAPIKit/Response/Response.swift

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ extension OpenAPI {
1111
/// OpenAPI Spec "Response Object"
1212
///
1313
/// See [OpenAPI Response Object](https://spec.openapis.org/oas/v3.1.1.html#response-object).
14-
public struct Response: Equatable, CodableVendorExtendable, Sendable {
15-
public var description: String
14+
public struct Response: HasConditionalWarnings, CodableVendorExtendable, Sendable {
15+
public var summary: String?
16+
public var description: String?
17+
1618
public var headers: Header.Map?
1719
/// An empty Content map will be omitted from encoding.
1820
public var content: Content.Map
@@ -26,22 +28,62 @@ extension OpenAPI {
2628
/// where the values are anything codable.
2729
public var vendorExtensions: [String: AnyCodable]
2830

31+
public let conditionalWarnings: [(any Condition, OpenAPI.Warning)]
32+
2933
public init(
30-
description: String,
34+
summary: String? = nil,
35+
description: String? = nil,
3136
headers: Header.Map? = nil,
3237
content: Content.Map = [:],
3338
links: Link.Map = [:],
3439
vendorExtensions: [String: AnyCodable] = [:]
3540
) {
41+
self.summary = summary
3642
self.description = description
3743
self.headers = headers
3844
self.content = content
3945
self.links = links
4046
self.vendorExtensions = vendorExtensions
47+
48+
self.conditionalWarnings = [
49+
// If summary is non-nil, the document must be OAS version 3.2.0 or greater
50+
nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0),
51+
// If description is nil, the document must be OAS version 3.2.0 or greater
52+
notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0)
53+
].compactMap { $0 }
4154
}
4255
}
4356
}
4457

58+
extension OpenAPI.Response: Equatable {
59+
public static func == (_ lhs: Self, _ rhs: Self) -> Bool {
60+
lhs.summary == rhs.summary
61+
&& lhs.description == rhs.description
62+
&& lhs.headers == rhs.headers
63+
&& lhs.content == rhs.content
64+
&& lhs.links == rhs.links
65+
&& lhs.vendorExtensions == rhs.vendorExtensions
66+
}
67+
}
68+
69+
fileprivate func nonNilVersionWarning<Subject>(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? {
70+
value.map { _ in
71+
OpenAPI.Document.ConditionalWarnings.version(
72+
lessThan: minimumVersion,
73+
doesNotSupport: "The Response \(fieldName) field"
74+
)
75+
}
76+
}
77+
78+
fileprivate func notOptionalVersionWarning<Subject>(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? {
79+
guard value == nil else { return nil }
80+
81+
return OpenAPI.Document.ConditionalWarnings.version(
82+
lessThan: minimumVersion,
83+
doesNotAllowOptional: "The Response \(fieldName) field"
84+
)
85+
}
86+
4587
extension OpenAPI.Response {
4688
public typealias Map = OrderedDictionary<StatusCode, Either<OpenAPI.Reference<OpenAPI.Response>, OpenAPI.Response>>
4789
}
@@ -72,7 +114,8 @@ extension OrderedDictionary where Key == OpenAPI.Response.StatusCode {
72114
extension Either where A == OpenAPI.Reference<OpenAPI.Response>, B == OpenAPI.Response {
73115

74116
public static func response(
75-
description: String,
117+
summary: String? = nil,
118+
description: String? = nil,
76119
headers: OpenAPI.Header.Map? = nil,
77120
content: OpenAPI.Content.Map = [:],
78121
links: OpenAPI.Link.Map = [:]
@@ -89,19 +132,27 @@ extension Either where A == OpenAPI.Reference<OpenAPI.Response>, B == OpenAPI.Re
89132
}
90133

91134
// MARK: - Describable
92-
extension OpenAPI.Response: OpenAPIDescribable {
135+
extension OpenAPI.Response: OpenAPISummarizable {
93136
public func overriddenNonNil(description: String?) -> OpenAPI.Response {
94137
guard let description = description else { return self }
95138
var response = self
96139
response.description = description
97140
return response
98141
}
142+
143+
public func overriddenNonNil(summary: String?) -> OpenAPI.Response {
144+
guard let summary = summary else { return self }
145+
var response = self
146+
response.summary = summary
147+
return response
148+
}
99149
}
100150

101151
// MARK: - Codable
102152

103153
extension OpenAPI.Response {
104154
internal enum CodingKeys: ExtendableCodingKey {
155+
case summary
105156
case description
106157
case headers
107158
case content
@@ -110,6 +161,7 @@ extension OpenAPI.Response {
110161

111162
static var allBuiltinKeys: [CodingKeys] {
112163
return [
164+
.summary,
113165
.description,
114166
.headers,
115167
.content,
@@ -123,6 +175,8 @@ extension OpenAPI.Response {
123175

124176
init?(stringValue: String) {
125177
switch stringValue {
178+
case "summary":
179+
self = .summary
126180
case "description":
127181
self = .description
128182
case "headers":
@@ -138,6 +192,8 @@ extension OpenAPI.Response {
138192

139193
var stringValue: String {
140194
switch self {
195+
case .summary:
196+
return "summary"
141197
case .description:
142198
return "description"
143199
case .headers:
@@ -157,7 +213,8 @@ extension OpenAPI.Response: Encodable {
157213
public func encode(to encoder: Encoder) throws {
158214
var container = encoder.container(keyedBy: CodingKeys.self)
159215

160-
try container.encode(description, forKey: .description)
216+
try container.encodeIfPresent(summary, forKey: .summary)
217+
try container.encodeIfPresent(description, forKey: .description)
161218
try container.encodeIfPresent(headers, forKey: .headers)
162219

163220
if !content.isEmpty {
@@ -179,13 +236,21 @@ extension OpenAPI.Response: Decodable {
179236
let container = try decoder.container(keyedBy: CodingKeys.self)
180237

181238
do {
182-
description = try container.decode(String.self, forKey: .description)
239+
summary = try container.decodeIfPresent(String.self, forKey: .summary)
240+
description = try container.decodeIfPresent(String.self, forKey: .description)
183241
headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers)
184242
content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:]
185243
links = try container.decodeIfPresent(OpenAPI.Link.Map.self, forKey: .links) ?? [:]
186244

187245
vendorExtensions = try Self.extensions(from: decoder)
188246

247+
conditionalWarnings = [
248+
// If summary is non-nil, the document must be OAS version 3.2.0 or greater
249+
nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0),
250+
// If description is nil, the document must be OAS version 3.2.0 or greater
251+
notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0)
252+
].compactMap { $0 }
253+
189254
} catch let error as GenericError {
190255

191256
throw OpenAPI.Error.Decoding.Response(error)

Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,36 +52,6 @@ final class ResponseErrorTests: XCTestCase {
5252
}
5353
}
5454

55-
func test_missingDescriptionResponseObject() {
56-
let documentYML =
57-
"""
58-
openapi: "3.1.0"
59-
info:
60-
title: test
61-
version: 1.0
62-
paths:
63-
/hello/world:
64-
get:
65-
responses:
66-
'200':
67-
not-a-thing: hi
68-
"""
69-
70-
XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in
71-
72-
let openAPIError = OpenAPI.Error(from: error)
73-
74-
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..")
75-
XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [
76-
"paths",
77-
"/hello/world",
78-
"get",
79-
"responses",
80-
"200"
81-
])
82-
}
83-
}
84-
8555
func test_badResponseExtension() {
8656
let documentYML =
8757
"""

Tests/OpenAPIKitTests/Response/ResponseTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@ final class ResponseTests: XCTestCase {
2525
XCTAssertEqual(r2.description, "")
2626
XCTAssertEqual(r2.headers?["hello"]?.headerValue, header)
2727
XCTAssertEqual(r2.content, [.json: content])
28+
XCTAssertEqual(r2.conditionalWarnings.count, 0)
29+
30+
// two OAS 3.2.0 warnings: summary is used and description is not
31+
let r3 = OpenAPI.Response(summary: "",
32+
content: [:])
33+
XCTAssertEqual(r3.summary, "")
34+
XCTAssertNil(r3.description)
35+
XCTAssertEqual(r3.conditionalWarnings.count, 2)
36+
37+
// one OAS 3.2.0 warnings: summary is used
38+
let r4 = OpenAPI.Response(summary: "",
39+
description: "",
40+
content: [:])
41+
XCTAssertEqual(r4.summary, "")
42+
XCTAssertEqual(r4.description, "")
43+
XCTAssertEqual(r4.conditionalWarnings.count, 1)
44+
45+
// one OAS 3.2.0 warnings: description is not used
46+
let r5 = OpenAPI.Response(content: [:])
47+
XCTAssertNil(r5.summary)
48+
XCTAssertNil(r5.description)
49+
XCTAssertEqual(r5.conditionalWarnings.count, 1)
2850
}
2951

3052
func test_responseMap() {
@@ -122,6 +144,18 @@ extension ResponseTests {
122144
}
123145
"""
124146
)
147+
148+
let response3 = OpenAPI.Response(summary: "", content: [:])
149+
let encodedResponse3 = try! orderUnstableTestStringFromEncoding(of: response3)
150+
151+
assertJSONEquivalent(
152+
encodedResponse3,
153+
"""
154+
{
155+
"summary" : ""
156+
}
157+
"""
158+
)
125159
}
126160

127161
func test_emptyDescriptionEmptyContent_decode() {
@@ -157,6 +191,16 @@ extension ResponseTests {
157191
let response3 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData3)
158192

159193
XCTAssertEqual(response3, OpenAPI.Response(description: "", headers: [:], content: [:]))
194+
195+
let responseData4 =
196+
"""
197+
{
198+
"summary" : ""
199+
}
200+
""".data(using: .utf8)!
201+
let response4 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData4)
202+
203+
XCTAssertEqual(response4, OpenAPI.Response(summary: "", content: [:]))
160204
}
161205

162206
func test_populatedDescriptionPopulatedContent_encode() {

documentation/migration_guides/v5_migration_guide.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ specification) in this section.
159159
A new `cookie` style has been added. Code that exhaustively switches on the
160160
`OpenAPI.Parameter.SchemaContext.Style` enum will need to be updated.
161161

162+
### Response Objects
163+
There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x
164+
specification) in this section.
165+
166+
The Response Object `description` field is not optional so code may need to
167+
change to account for it possibly being `nil`.
168+
162169
### Errors
163170
Some error messages have been tweaked in small ways. If you match on the
164171
string descriptions of any OpenAPIKit errors, you may need to update the

0 commit comments

Comments
 (0)