Skip to content

Commit 333d73a

Browse files
authored
[Runtime] Improved content type matching (#65)
[Runtime] Improved content type matching ### Motivation The runtime changes for apple/swift-openapi-generator#315. ### Modifications - Introduces a new SPI method `Converter.bestContentType` that takes a received content type value and from a provided list of other content types, picks the most appropriate one. This actually follows the specification now, by going from most specific (including parameter matching) to least specific (most wildcard-y). - Deprecates the previously used methods `Converter.makeUnexpectedContentTypeError` and `Converter.isMatchingContentType`. ### Result SPI methods that the generated code can use to correctly match content types. ### Test Plan Added unit tests. Reviewed by: gjcairo Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ 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. #65
1 parent 91b16be commit 333d73a

File tree

5 files changed

+436
-37
lines changed

5 files changed

+436
-37
lines changed

Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,115 @@ extension OpenAPIMIMEType: LosslessStringConvertible {
187187
.joined(separator: "; ")
188188
}
189189
}
190+
191+
// MARK: - Internals
192+
193+
extension OpenAPIMIMEType {
194+
195+
/// The result of a match evaluation between two MIME types.
196+
enum Match: Hashable {
197+
198+
/// The reason why two types are incompatible.
199+
enum IncompatibilityReason: Hashable {
200+
201+
/// The types don't match.
202+
case type
203+
204+
/// The subtypes don't match.
205+
case subtype
206+
207+
/// The parameter of the provided name is missing or doesn't match.
208+
case parameter(name: String)
209+
}
210+
211+
/// The types are incompatible for the provided reason.
212+
case incompatible(IncompatibilityReason)
213+
214+
/// The types match based on a full wildcard `*/*`.
215+
case wildcard
216+
217+
/// The types match based on a subtype wildcard, such as `image/*`.
218+
case subtypeWildcard
219+
220+
/// The types match across the type, subtype, and the provided number
221+
/// of parameters.
222+
case typeAndSubtype(matchedParameterCount: Int)
223+
224+
/// A numeric representation of the quality of the match, the higher
225+
/// the closer the types are.
226+
var score: Int {
227+
switch self {
228+
case .incompatible:
229+
return 0
230+
case .wildcard:
231+
return 1
232+
case .subtypeWildcard:
233+
return 2
234+
case .typeAndSubtype(let matchedParameterCount):
235+
return 3 + matchedParameterCount
236+
}
237+
}
238+
}
239+
240+
/// Computes whether two MIME types match.
241+
/// - Parameters:
242+
/// - receivedType: The type component of the received MIME type.
243+
/// - receivedSubtype: The subtype component of the received MIME type.
244+
/// - receivedParameters: The parameters of the received MIME type.
245+
/// - option: The MIME type to match against.
246+
/// - Returns: The match result.
247+
static func evaluate(
248+
receivedType: String,
249+
receivedSubtype: String,
250+
receivedParameters: [String: String],
251+
against option: OpenAPIMIMEType
252+
) -> Match {
253+
switch option.kind {
254+
case .any:
255+
return .wildcard
256+
case .anySubtype(let expectedType):
257+
guard receivedType.lowercased() == expectedType.lowercased() else {
258+
return .incompatible(.type)
259+
}
260+
return .subtypeWildcard
261+
case .concrete(let expectedType, let expectedSubtype):
262+
guard
263+
receivedType.lowercased() == expectedType.lowercased()
264+
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
265+
else {
266+
return .incompatible(.subtype)
267+
}
268+
269+
// A full concrete match, so also check parameters.
270+
// The rule is:
271+
// 1. If a received parameter is not found in the option,
272+
// that's okay and gets ignored.
273+
// 2. If an option parameter is not received, this is an
274+
// incompatible content type match.
275+
// This means we can just iterate over option parameters and
276+
// check them against the received parameters, but we can
277+
// ignore any received parameters that didn't appear in the
278+
// option parameters.
279+
280+
// According to RFC 2045: https://www.rfc-editor.org/rfc/rfc2045#section-5.1
281+
// "Type, subtype, and parameter names are case-insensitive."
282+
// Inferred: Parameter values are case-sensitive.
283+
284+
let receivedNormalizedParameters = Dictionary(
285+
uniqueKeysWithValues: receivedParameters.map { ($0.key.lowercased(), $0.value) }
286+
)
287+
var matchedParameterCount = 0
288+
for optionParameter in option.parameters {
289+
let normalizedParameterName = optionParameter.key.lowercased()
290+
guard
291+
let receivedValue = receivedNormalizedParameters[normalizedParameterName],
292+
receivedValue == optionParameter.value
293+
else {
294+
return .incompatible(.parameter(name: normalizedParameterName))
295+
}
296+
matchedParameterCount += 1
297+
}
298+
return .typeAndSubtype(matchedParameterCount: matchedParameterCount)
299+
}
300+
}
301+
}

Sources/OpenAPIRuntime/Conversion/Converter+Common.swift

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,45 +29,51 @@ extension Converter {
2929
return OpenAPIMIMEType(rawValue)
3030
}
3131

32-
/// Checks whether a concrete content type matches an expected content type.
33-
///
34-
/// The concrete content type can contain parameters, such as `charset`, but
35-
/// they are ignored in the equality comparison.
36-
///
37-
/// The expected content type can contain wildcards, such as */* and text/*.
32+
/// Chooses the most appropriate content type for the provided received
33+
/// content type and a list of options.
3834
/// - Parameters:
39-
/// - received: The concrete content type to validate against the other.
40-
/// - expectedRaw: The expected content type, can contain wildcards.
41-
/// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type.
42-
/// - Returns: A Boolean value representing whether the concrete content
43-
/// type matches the expected one.
44-
public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool {
45-
guard let received else {
46-
return false
47-
}
48-
guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else {
49-
return false
35+
/// - received: The received content type.
36+
/// - options: The options to match against.
37+
/// - Returns: The most appropriate option.
38+
/// - Throws: If none of the options match the received content type.
39+
/// - Precondition: `options` must not be empty.
40+
public func bestContentType(
41+
received: OpenAPIMIMEType?,
42+
options: [String]
43+
) throws -> String {
44+
precondition(!options.isEmpty, "bestContentType options must not be empty.")
45+
guard
46+
let received,
47+
case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind
48+
else {
49+
// If none received or if we received a wildcard, use the first one.
50+
// This behavior isn't well defined by the OpenAPI specification.
51+
// Note: We treat a partial wildcard, like `image/*` as a full
52+
// wildcard `*/*`, but that's okay because for a concrete received
53+
// content type the behavior of a wildcard is not clearly defined
54+
// either.
55+
return options[0]
5056
}
51-
guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else {
52-
throw RuntimeError.invalidExpectedContentType(expectedRaw)
57+
let evaluatedOptions = try options.map { stringOption in
58+
guard let parsedOption = OpenAPIMIMEType(stringOption) else {
59+
throw RuntimeError.invalidExpectedContentType(stringOption)
60+
}
61+
let match = OpenAPIMIMEType.evaluate(
62+
receivedType: receivedType,
63+
receivedSubtype: receivedSubtype,
64+
receivedParameters: received.parameters,
65+
against: parsedOption
66+
)
67+
return (contentType: stringOption, match: match)
5368
}
54-
switch expectedContentType.kind {
55-
case .any:
56-
return true
57-
case .anySubtype(let expectedType):
58-
return receivedType.lowercased() == expectedType.lowercased()
59-
case .concrete(let expectedType, let expectedSubtype):
60-
return receivedType.lowercased() == expectedType.lowercased()
61-
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
69+
let bestOption = evaluatedOptions.max { a, b in
70+
a.match.score < b.match.score
71+
}! // Safe, we only get here if the array is not empty.
72+
let bestContentType = bestOption.contentType
73+
if case .incompatible = bestOption.match {
74+
throw RuntimeError.unexpectedContentTypeHeader(bestContentType)
6275
}
63-
}
64-
65-
/// Returns an error to be thrown when an unexpected content type is
66-
/// received.
67-
/// - Parameter contentType: The content type that was received.
68-
/// - Returns: An error representing an unexpected content type.
69-
public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error {
70-
RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "")
76+
return bestContentType
7177
}
7278

7379
// MARK: - Converter helper methods

Sources/OpenAPIRuntime/Deprecated/Deprecated.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,48 @@ extension ServerError {
9898
)
9999
}
100100
}
101+
102+
extension Converter {
103+
/// Returns an error to be thrown when an unexpected content type is
104+
/// received.
105+
/// - Parameter contentType: The content type that was received.
106+
/// - Returns: An error representing an unexpected content type.
107+
@available(*, deprecated)
108+
public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error {
109+
RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "")
110+
}
111+
112+
/// Checks whether a concrete content type matches an expected content type.
113+
///
114+
/// The concrete content type can contain parameters, such as `charset`, but
115+
/// they are ignored in the equality comparison.
116+
///
117+
/// The expected content type can contain wildcards, such as */* and text/*.
118+
/// - Parameters:
119+
/// - received: The concrete content type to validate against the other.
120+
/// - expectedRaw: The expected content type, can contain wildcards.
121+
/// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type.
122+
/// - Returns: A Boolean value representing whether the concrete content
123+
/// type matches the expected one.
124+
@available(*, deprecated)
125+
public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool {
126+
guard let received else {
127+
return false
128+
}
129+
guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else {
130+
return false
131+
}
132+
guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else {
133+
throw RuntimeError.invalidExpectedContentType(expectedRaw)
134+
}
135+
switch expectedContentType.kind {
136+
case .any:
137+
return true
138+
case .anySubtype(let expectedType):
139+
return receivedType.lowercased() == expectedType.lowercased()
140+
case .concrete(let expectedType, let expectedSubtype):
141+
return receivedType.lowercased() == expectedType.lowercased()
142+
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
143+
}
144+
}
145+
}

Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414
import XCTest
15-
@_spi(Generated) import OpenAPIRuntime
15+
@_spi(Generated) @testable import OpenAPIRuntime
1616

1717
final class Test_OpenAPIMIMEType: Test_Runtime {
18-
func test() throws {
18+
func testParsing() throws {
1919
let cases: [(String, OpenAPIMIMEType?, String?)] = [
2020

2121
// Common
@@ -87,4 +87,92 @@ final class Test_OpenAPIMIMEType: Test_Runtime {
8787
XCTAssertEqual(mime?.description, outputString)
8888
}
8989
}
90+
91+
func testScore() throws {
92+
let cases: [(OpenAPIMIMEType.Match, Int)] = [
93+
94+
(.incompatible(.type), 0),
95+
(.incompatible(.subtype), 0),
96+
(.incompatible(.parameter(name: "foo")), 0),
97+
98+
(.wildcard, 1),
99+
100+
(.subtypeWildcard, 2),
101+
102+
(.typeAndSubtype(matchedParameterCount: 0), 3),
103+
(.typeAndSubtype(matchedParameterCount: 2), 5),
104+
]
105+
for (match, score) in cases {
106+
XCTAssertEqual(match.score, score, "Mismatch for match: \(match)")
107+
}
108+
}
109+
110+
func testEvaluate() throws {
111+
func testCase(
112+
receivedType: String,
113+
receivedSubtype: String,
114+
receivedParameters: [String: String],
115+
against option: OpenAPIMIMEType,
116+
expected expectedMatch: OpenAPIMIMEType.Match,
117+
file: StaticString = #file,
118+
line: UInt = #line
119+
) {
120+
let result = OpenAPIMIMEType.evaluate(
121+
receivedType: receivedType,
122+
receivedSubtype: receivedSubtype,
123+
receivedParameters: receivedParameters,
124+
against: option
125+
)
126+
XCTAssertEqual(result, expectedMatch, file: file, line: line)
127+
}
128+
129+
let jsonWith2Params = OpenAPIMIMEType("application/json; charset=utf-8; version=1")!
130+
let jsonWith1Param = OpenAPIMIMEType("application/json; charset=utf-8")!
131+
let json = OpenAPIMIMEType("application/json")!
132+
let fullWildcard = OpenAPIMIMEType("*/*")!
133+
let subtypeWildcard = OpenAPIMIMEType("application/*")!
134+
135+
func testJSONWith2Params(
136+
against option: OpenAPIMIMEType,
137+
expected expectedMatch: OpenAPIMIMEType.Match,
138+
file: StaticString = #file,
139+
line: UInt = #line
140+
) {
141+
testCase(
142+
receivedType: "application",
143+
receivedSubtype: "json",
144+
receivedParameters: [
145+
"charset": "utf-8",
146+
"version": "1",
147+
],
148+
against: option,
149+
expected: expectedMatch,
150+
file: file,
151+
line: line
152+
)
153+
}
154+
155+
// Actual test cases start here.
156+
157+
testJSONWith2Params(
158+
against: jsonWith2Params,
159+
expected: .typeAndSubtype(matchedParameterCount: 2)
160+
)
161+
testJSONWith2Params(
162+
against: jsonWith1Param,
163+
expected: .typeAndSubtype(matchedParameterCount: 1)
164+
)
165+
testJSONWith2Params(
166+
against: json,
167+
expected: .typeAndSubtype(matchedParameterCount: 0)
168+
)
169+
testJSONWith2Params(
170+
against: subtypeWildcard,
171+
expected: .subtypeWildcard
172+
)
173+
testJSONWith2Params(
174+
against: fullWildcard,
175+
expected: .wildcard
176+
)
177+
}
90178
}

0 commit comments

Comments
 (0)