Skip to content

Commit 6a37848

Browse files
authored
[Runtime] Multiple content types support (#29)
[Runtime] Multiple content types support Runtime changes for apple/swift-openapi-generator#146 ### Motivation See apple/swift-openapi-generator#146 for motivation. ### Modifications Adds new methods for: - `extractContentTypeIfPresent` - returns the `content-type` header - `isValidContentType` - evaluates content type equivalence, including wildcards - `makeUnexpectedContentTypeError` - returns an error that the generated code can throw Deprecates this method, which will be removed in a future breaking version: - `validateContentTypeIfPresent` - only made sense when we supported only 1 content type value for a body ### Result Enables generated code to support multiple content types. ### Test Plan Deprecated the tests for the deprecated method, and added unit tests for the new methods. Reviewed by: gjcairo, simonjbeaumont Builds: ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (api breakage) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. #29
1 parent b9019d9 commit 6a37848

File tree

7 files changed

+418
-72
lines changed

7 files changed

+418
-72
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import Foundation
15+
16+
/// A container for a parsed, valid MIME type.
17+
@_spi(Generated)
18+
public struct OpenAPIMIMEType: Equatable {
19+
20+
/// The kind of the MIME type.
21+
public enum Kind: Equatable {
22+
23+
/// Any, spelled as `*/*`.
24+
case any
25+
26+
/// Any subtype of a concrete type, spelled as `type/*`.
27+
case anySubtype(type: String)
28+
29+
/// A concrete value, spelled as `type/subtype`.
30+
case concrete(type: String, subtype: String)
31+
32+
public static func == (lhs: Kind, rhs: Kind) -> Bool {
33+
switch (lhs, rhs) {
34+
case (.any, .any):
35+
return true
36+
case let (.anySubtype(lhsType), .anySubtype(rhsType)):
37+
return lhsType.lowercased() == rhsType.lowercased()
38+
case let (.concrete(lhsType, lhsSubtype), .concrete(rhsType, rhsSubtype)):
39+
return lhsType.lowercased() == rhsType.lowercased()
40+
&& lhsSubtype.lowercased() == rhsSubtype.lowercased()
41+
default:
42+
return false
43+
}
44+
}
45+
}
46+
47+
/// The kind of the MIME type.
48+
public var kind: Kind
49+
50+
/// Any optional parameters.
51+
public var parameters: [String: String]
52+
53+
/// Creates a new MIME type.
54+
/// - Parameters:
55+
/// - kind: The kind of the MIME type.
56+
/// - parameters: Any optional parameters.
57+
public init(kind: Kind, parameters: [String: String] = [:]) {
58+
self.kind = kind
59+
self.parameters = parameters
60+
}
61+
62+
public static func == (lhs: OpenAPIMIMEType, rhs: OpenAPIMIMEType) -> Bool {
63+
guard lhs.kind == rhs.kind else {
64+
return false
65+
}
66+
// Parameter names are case-insensitive, parameter values are
67+
// case-sensitive.
68+
guard lhs.parameters.count == rhs.parameters.count else {
69+
return false
70+
}
71+
if lhs.parameters.isEmpty {
72+
return true
73+
}
74+
func normalizeKeyValue(key: String, value: String) -> (String, String) {
75+
(key.lowercased(), value)
76+
}
77+
let normalizedLeftParams = Dictionary(
78+
uniqueKeysWithValues: lhs.parameters.map(normalizeKeyValue)
79+
)
80+
let normalizedRightParams = Dictionary(
81+
uniqueKeysWithValues: rhs.parameters.map(normalizeKeyValue)
82+
)
83+
return normalizedLeftParams == normalizedRightParams
84+
}
85+
}
86+
87+
extension OpenAPIMIMEType.Kind: LosslessStringConvertible {
88+
public init?(_ description: String) {
89+
let typeAndSubtype =
90+
description
91+
.split(separator: "/")
92+
.map(String.init)
93+
guard typeAndSubtype.count == 2 else {
94+
return nil
95+
}
96+
switch (typeAndSubtype[0], typeAndSubtype[1]) {
97+
case ("*", let subtype):
98+
guard subtype == "*" else {
99+
return nil
100+
}
101+
self = .any
102+
case (let type, "*"):
103+
self = .anySubtype(type: type)
104+
case (let type, let subtype):
105+
self = .concrete(type: type, subtype: subtype)
106+
}
107+
}
108+
109+
public var description: String {
110+
switch self {
111+
case .any:
112+
return "*/*"
113+
case .anySubtype(let type):
114+
return "\(type)/*"
115+
case .concrete(let type, let subtype):
116+
return "\(type)/\(subtype)"
117+
}
118+
}
119+
}
120+
121+
extension OpenAPIMIMEType: LosslessStringConvertible {
122+
public init?(_ description: String) {
123+
var components =
124+
description
125+
// Split by semicolon
126+
.split(separator: ";")
127+
.map(String.init)
128+
// Trim leading/trailing spaces
129+
.map { $0.trimmingLeadingAndTrailingSpaces }
130+
guard !components.isEmpty else {
131+
return nil
132+
}
133+
let firstComponent = components.removeFirst()
134+
guard let kind = OpenAPIMIMEType.Kind(firstComponent) else {
135+
return nil
136+
}
137+
func parseParameter(_ string: String) -> (String, String)? {
138+
let components =
139+
string
140+
.split(separator: "=")
141+
.map(String.init)
142+
guard components.count == 2 else {
143+
return nil
144+
}
145+
return (components[0], components[1])
146+
}
147+
let parameters =
148+
components
149+
.compactMap(parseParameter)
150+
self.init(
151+
kind: kind,
152+
parameters: Dictionary(
153+
parameters,
154+
// Pick the first value when duplicate parameters are provided.
155+
uniquingKeysWith: { a, _ in a }
156+
)
157+
)
158+
}
159+
160+
public var description: String {
161+
([kind.description]
162+
+ parameters
163+
.sorted(by: { a, b in a.key < b.key })
164+
.map { "\($0)=\($1)" })
165+
.joined(separator: "; ")
166+
}
167+
}
168+
169+
extension String {
170+
fileprivate var trimmingLeadingAndTrailingSpaces: Self {
171+
trimmingCharacters(in: .whitespacesAndNewlines)
172+
}
173+
}

Sources/OpenAPIRuntime/Conversion/Converter+Common.swift

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,55 @@ extension Converter {
1717

1818
// MARK: Miscs
1919

20-
/// Validates that the Content-Type header field (if present)
21-
/// is compatible with the provided content-type substring.
20+
/// Returns the MIME type from the content-type header, if present.
21+
/// - Parameter headerFields: The header fields to inspect for the content
22+
/// type header.
23+
/// - Returns: The content type value, or nil if not found or invalid.
24+
public func extractContentTypeIfPresent(in headerFields: [HeaderField]) -> OpenAPIMIMEType? {
25+
guard let rawValue = headerFields.firstValue(name: "content-type") else {
26+
return nil
27+
}
28+
return OpenAPIMIMEType(rawValue)
29+
}
30+
31+
/// Checks whether a concrete content type matches an expected content type.
2232
///
23-
/// Succeeds if no Content-Type header is found in the response headers.
33+
/// The concrete content type can contain parameters, such as `charset`, but
34+
/// they are ignored in the equality comparison.
2435
///
36+
/// The expected content type can contain wildcards, such as */* and text/*.
2537
/// - Parameters:
26-
/// - headerFields: Header fields to inspect for a content type.
27-
/// - substring: Expected content type.
28-
/// - Throws: If the response's Content-Type value is not compatible with the provided substring.
29-
public func validateContentTypeIfPresent(
30-
in headerFields: [HeaderField],
31-
substring: String
32-
) throws {
33-
guard
34-
let contentType = try getOptionalHeaderFieldAsText(
35-
in: headerFields,
36-
name: "content-type",
37-
as: String.self
38-
)
39-
else {
40-
return
38+
/// - received: The concrete content type to validate against the other.
39+
/// - expectedRaw: The expected content type, can contain wildcards.
40+
/// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type.
41+
/// - Returns: A Boolean value representing whether the concrete content
42+
/// type matches the expected one.
43+
public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool {
44+
guard let received else {
45+
return false
4146
}
42-
guard contentType.localizedCaseInsensitiveContains(substring) else {
43-
throw RuntimeError.unexpectedContentTypeHeader(contentType)
47+
guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else {
48+
return false
4449
}
50+
guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else {
51+
throw RuntimeError.invalidExpectedContentType(expectedRaw)
52+
}
53+
switch expectedContentType.kind {
54+
case .any:
55+
return true
56+
case .anySubtype(let expectedType):
57+
return receivedType.lowercased() == expectedType.lowercased()
58+
case .concrete(let expectedType, let expectedSubtype):
59+
return receivedType.lowercased() == expectedType.lowercased()
60+
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
61+
}
62+
}
63+
64+
/// Returns an error to be thrown when an unexpected content type is
65+
/// received.
66+
/// - Parameter contentType: The content type that was received.
67+
public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error {
68+
RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "")
4569
}
4670

4771
// MARK: - Converter helper methods

Sources/OpenAPIRuntime/Deprecated/Deprecated.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,29 @@ extension Converter {
729729

730730
extension Converter {
731731

732+
/// Validates that the Content-Type header field (if present)
733+
/// is compatible with the provided content-type substring.
734+
///
735+
/// Succeeds if no Content-Type header is found in the response headers.
736+
///
737+
/// - Parameters:
738+
/// - headerFields: Header fields to inspect for a content type.
739+
/// - substring: Expected content type.
740+
/// - Throws: If the response's Content-Type value is not compatible with
741+
/// the provided substring.
742+
@available(*, deprecated, message: "Use isMatchingContentType instead.")
743+
public func validateContentTypeIfPresent(
744+
in headerFields: [HeaderField],
745+
substring: String
746+
) throws {
747+
guard let contentType = extractContentTypeIfPresent(in: headerFields) else {
748+
return
749+
}
750+
guard try isMatchingContentType(received: contentType, expectedRaw: substring) else {
751+
throw RuntimeError.unexpectedContentTypeHeader(contentType.description)
752+
}
753+
}
754+
732755
// | client | set | request body | text | string-convertible | optional | setOptionalRequestBodyAsText |
733756
@available(*, deprecated)
734757
public func setOptionalRequestBodyAsText<T: _StringConvertible, C>(

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
1919

2020
// Miscs
2121
case invalidServerURL(String)
22+
case invalidExpectedContentType(String)
2223

2324
// Data conversion
2425
case failedToDecodeStringConvertibleValue(type: String)
@@ -51,6 +52,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
5152
switch self {
5253
case .invalidServerURL(let string):
5354
return "Invalid server URL: \(string)"
55+
case .invalidExpectedContentType(let string):
56+
return "Invalid expected content type: '\(string)'"
5457
case .failedToDecodeStringConvertibleValue(let string):
5558
return "Failed to decode a value of type '\(string)'."
5659
case .missingRequiredHeaderField(let name):
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import XCTest
15+
@_spi(Generated) import OpenAPIRuntime
16+
17+
final class Test_OpenAPIMIMEType: Test_Runtime {
18+
func test() throws {
19+
let cases: [(String, OpenAPIMIMEType?, String?)] = [
20+
21+
// Common
22+
(
23+
"application/json",
24+
OpenAPIMIMEType(kind: .concrete(type: "application", subtype: "json")),
25+
"application/json"
26+
),
27+
28+
// Subtype wildcard
29+
(
30+
"application/*",
31+
OpenAPIMIMEType(kind: .anySubtype(type: "application")),
32+
"application/*"
33+
),
34+
35+
// Type wildcard
36+
(
37+
"*/*",
38+
OpenAPIMIMEType(kind: .any),
39+
"*/*"
40+
),
41+
42+
// Common with a parameter
43+
(
44+
"application/json; charset=UTF-8",
45+
OpenAPIMIMEType(
46+
kind: .concrete(type: "application", subtype: "json"),
47+
parameters: [
48+
"charset": "UTF-8"
49+
]
50+
),
51+
"application/json; charset=UTF-8"
52+
),
53+
54+
// Common with two parameters
55+
(
56+
"application/json; charset=UTF-8; boundary=1234",
57+
OpenAPIMIMEType(
58+
kind: .concrete(type: "application", subtype: "json"),
59+
parameters: [
60+
"charset": "UTF-8",
61+
"boundary": "1234",
62+
]
63+
),
64+
"application/json; boundary=1234; charset=UTF-8"
65+
),
66+
67+
// Common case preserving, but case insensitive equality
68+
(
69+
"APPLICATION/JSON;CHARSET=UTF-8",
70+
OpenAPIMIMEType(
71+
kind: .concrete(type: "application", subtype: "json"),
72+
parameters: [
73+
"charset": "UTF-8"
74+
]
75+
),
76+
"APPLICATION/JSON; CHARSET=UTF-8"
77+
),
78+
79+
// Invalid
80+
("application", nil, nil),
81+
("application/foo/bar", nil, nil),
82+
("", nil, nil),
83+
]
84+
for (inputString, expectedMIME, outputString) in cases {
85+
let mime = OpenAPIMIMEType(inputString)
86+
XCTAssertEqual(mime, expectedMIME)
87+
XCTAssertEqual(mime?.description, outputString)
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)