Skip to content

Commit b820948

Browse files
authored
[Runtime] Improve parameter handling of MIME types in content types (#113)
### Motivation In service of an upcoming generator PR - to better handle parameters in MIME types. This is needed for APIs such as Kubernetes, that has both `application/json` and `application/json; watch=true` in a single operation. ### Modifications Use the more modern `OpenAPIMIMEType` type for parsing and comparing content types, rather than the old naive logic that ignored parameter (mis)matches. ### Result More accurate handling of content types. ### Test Plan Adapted unit tests.
1 parent 2cb09fb commit b820948

File tree

3 files changed

+47
-8
lines changed

3 files changed

+47
-8
lines changed

Sources/OpenAPIRuntime/Conversion/Converter+Server.swift

+36-5
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,21 @@ extension Converter {
5656
// Drop everything after the optional semicolon (q, extensions, ...)
5757
value.split(separator: ";")[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
5858
}
59-
6059
if acceptValues.isEmpty { return }
61-
if acceptValues.contains("*/*") { return }
62-
if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { return }
63-
if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { return }
60+
guard let parsedSubstring = OpenAPIMIMEType(substring) else {
61+
throw RuntimeError.invalidAcceptSubstring(substring)
62+
}
63+
// Look for the first match.
64+
for acceptValue in acceptValues {
65+
// Fast path.
66+
if acceptValue == substring { return }
67+
guard let parsedAcceptValue = OpenAPIMIMEType(acceptValue) else {
68+
throw RuntimeError.invalidExpectedContentType(acceptValue)
69+
}
70+
if parsedSubstring.satisfies(acceptValue: parsedAcceptValue) { return }
71+
}
6472
throw RuntimeError.unexpectedAcceptHeader(acceptHeader)
6573
}
66-
6774
/// Retrieves and decodes a path parameter as a URI-encoded value of the specified type.
6875
///
6976
/// - Parameters:
@@ -469,3 +476,27 @@ extension Converter {
469476
)
470477
}
471478
}
479+
480+
fileprivate extension OpenAPIMIMEType {
481+
/// Checks if the type satisfies the provided Accept header value.
482+
/// - Parameter acceptValue: A parsed Accept header MIME type.
483+
/// - Returns: `true` if it satisfies the Accept header, `false` otherwise.
484+
func satisfies(acceptValue: OpenAPIMIMEType) -> Bool {
485+
switch (acceptValue.kind, self.kind) {
486+
case (.concrete, .any), (.concrete, .anySubtype), (.anySubtype, .any):
487+
// The response content-type must be at least as specific as the accept header.
488+
return false
489+
case (.any, _):
490+
// Accept: */* -- Any content-type satisfies the accept header.
491+
return true
492+
case (.anySubtype(let acceptType), .anySubtype(let substringType)),
493+
(.anySubtype(let acceptType), .concrete(let substringType, _)):
494+
// Accept: type/* -- The content-type should match the partially-specified accept header.
495+
return acceptType.lowercased() == substringType.lowercased()
496+
case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)):
497+
// Accept: type/subtype -- The content-type should match the concrete type.
498+
return acceptType.lowercased() == substringType.lowercased()
499+
&& acceptSubtype.lowercased() == substringSubtype.lowercased()
500+
}
501+
}
502+
}

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
2121
case invalidServerURL(String)
2222
case invalidServerVariableValue(name: String, value: String, allowedValues: [String])
2323
case invalidExpectedContentType(String)
24+
case invalidAcceptSubstring(String)
2425
case invalidHeaderFieldName(String)
2526
case invalidBase64String(String)
2627

@@ -85,6 +86,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
8586
return
8687
"Invalid server variable named: '\(name)', which has the value: '\(value)', but the only allowed values are: \(allowedValues.map { "'\($0)'" }.joined(separator: ", "))"
8788
case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'"
89+
case .invalidAcceptSubstring(let string): return "Invalid Accept header content type: '\(string)'"
8890
case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'"
8991
case .invalidBase64String(let string):
9092
return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'"

Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,31 @@ final class Test_ServerConverterExtensions: Test_Runtime {
3939
.accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
4040
]
4141
let multiple: HTTPFields = [.accept: "text/plain, application/json"]
42+
let params: HTTPFields = [.accept: "application/json; foo=bar"]
4243
let cases: [(HTTPFields, String, Bool)] = [
4344
// No Accept header, any string validates successfully
4445
(emptyHeaders, "foobar", true),
4546

46-
// Accept: */*, any string validates successfully
47-
(wildcard, "foobar", true),
47+
// Accept: */*, any MIME type validates successfully
48+
(wildcard, "foobaz/bar", true),
4849

4950
// Accept: text/*, so text/plain succeeds, application/json fails
5051
(partialWildcard, "text/plain", true), (partialWildcard, "application/json", false),
5152

5253
// Accept: text/plain, text/plain succeeds, application/json fails
53-
(short, "text/plain", true), (short, "application/json", false),
54+
(short, "text/plain", true), (short, "application/json", false), (short, "application/*", false),
55+
(short, "*/*", false),
5456

5557
// A bunch of acceptable content types
5658
(long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true),
5759
(long, "image/webp", true), (long, "application/json", true),
5860

5961
// Multiple values
6062
(multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false),
63+
64+
// Params
65+
(params, "application/json; foo=bar", true), (params, "application/json; charset=utf-8; foo=bar", true),
66+
(params, "application/json", true), (params, "text/plain", false),
6167
]
6268
for (headers, contentType, success) in cases {
6369
if success {

0 commit comments

Comments
 (0)