diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index 75b0f521..a3088bd3 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -56,14 +56,21 @@ extension Converter { // Drop everything after the optional semicolon (q, extensions, ...) value.split(separator: ";")[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - if acceptValues.isEmpty { return } - if acceptValues.contains("*/*") { return } - if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { return } - if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { return } + guard let parsedSubstring = OpenAPIMIMEType(substring) else { + throw RuntimeError.invalidAcceptSubstring(substring) + } + // Look for the first match. + for acceptValue in acceptValues { + // Fast path. + if acceptValue == substring { return } + guard let parsedAcceptValue = OpenAPIMIMEType(acceptValue) else { + throw RuntimeError.invalidExpectedContentType(acceptValue) + } + if parsedSubstring.satisfies(acceptValue: parsedAcceptValue) { return } + } throw RuntimeError.unexpectedAcceptHeader(acceptHeader) } - /// Retrieves and decodes a path parameter as a URI-encoded value of the specified type. /// /// - Parameters: @@ -469,3 +476,27 @@ extension Converter { ) } } + +fileprivate extension OpenAPIMIMEType { + /// Checks if the type satisfies the provided Accept header value. + /// - Parameter acceptValue: A parsed Accept header MIME type. + /// - Returns: `true` if it satisfies the Accept header, `false` otherwise. + func satisfies(acceptValue: OpenAPIMIMEType) -> Bool { + switch (acceptValue.kind, self.kind) { + case (.concrete, .any), (.concrete, .anySubtype), (.anySubtype, .any): + // The response content-type must be at least as specific as the accept header. + return false + case (.any, _): + // Accept: */* -- Any content-type satisfies the accept header. + return true + case (.anySubtype(let acceptType), .anySubtype(let substringType)), + (.anySubtype(let acceptType), .concrete(let substringType, _)): + // Accept: type/* -- The content-type should match the partially-specified accept header. + return acceptType.lowercased() == substringType.lowercased() + case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)): + // Accept: type/subtype -- The content-type should match the concrete type. + return acceptType.lowercased() == substringType.lowercased() + && acceptSubtype.lowercased() == substringSubtype.lowercased() + } + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index b0c776ed..549e3a13 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -21,6 +21,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case invalidServerURL(String) case invalidServerVariableValue(name: String, value: String, allowedValues: [String]) case invalidExpectedContentType(String) + case invalidAcceptSubstring(String) case invalidHeaderFieldName(String) case invalidBase64String(String) @@ -85,6 +86,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Invalid server variable named: '\(name)', which has the value: '\(value)', but the only allowed values are: \(allowedValues.map { "'\($0)'" }.joined(separator: ", "))" case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'" + case .invalidAcceptSubstring(let string): return "Invalid Accept header content type: '\(string)'" case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'" case .invalidBase64String(let string): return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'" diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index b2305a08..9e16fbfb 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -39,18 +39,20 @@ final class Test_ServerConverterExtensions: Test_Runtime { .accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8" ] let multiple: HTTPFields = [.accept: "text/plain, application/json"] + let params: HTTPFields = [.accept: "application/json; foo=bar"] let cases: [(HTTPFields, String, Bool)] = [ // No Accept header, any string validates successfully (emptyHeaders, "foobar", true), - // Accept: */*, any string validates successfully - (wildcard, "foobar", true), + // Accept: */*, any MIME type validates successfully + (wildcard, "foobaz/bar", true), // Accept: text/*, so text/plain succeeds, application/json fails (partialWildcard, "text/plain", true), (partialWildcard, "application/json", false), // Accept: text/plain, text/plain succeeds, application/json fails - (short, "text/plain", true), (short, "application/json", false), + (short, "text/plain", true), (short, "application/json", false), (short, "application/*", false), + (short, "*/*", false), // A bunch of acceptable content types (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), @@ -58,6 +60,10 @@ final class Test_ServerConverterExtensions: Test_Runtime { // Multiple values (multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false), + + // Params + (params, "application/json; foo=bar", true), (params, "application/json; charset=utf-8; foo=bar", true), + (params, "application/json", true), (params, "text/plain", false), ] for (headers, contentType, success) in cases { if success {