diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 3d7adef8..9fb5035f 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -210,10 +210,15 @@ extension OpenAPIMIMEType { guard receivedType.lowercased() == expectedType.lowercased() else { return .incompatible(.type) } return .subtypeWildcard case .concrete(let expectedType, let expectedSubtype): - guard - receivedType.lowercased() == expectedType.lowercased() - && receivedSubtype.lowercased() == expectedSubtype.lowercased() - else { return .incompatible(.subtype) } + let receivedTypeLowercased = receivedType.lowercased() + let expectedTypeLowercased = expectedType.lowercased() + guard receivedTypeLowercased == expectedTypeLowercased else { return .incompatible(.type) } + + let receivedSubtypeLowercased = receivedSubtype.lowercased() + let expectedSubtypeLowercased = expectedSubtype.lowercased() + guard Self.subtypesMatch(receivedSubtypeLowercased, expectedSubtypeLowercased) else { + return .incompatible(.subtype) + } // A full concrete match, so also check parameters. // The rule is: @@ -244,4 +249,43 @@ extension OpenAPIMIMEType { return .typeAndSubtype(matchedParameterCount: matchedParameterCount) } } + + /// Returns the structured syntax suffix component of a subtype, if present. + /// For example, returns `"json"` for `"problem+json"`. + static func structuredSyntaxSuffix(of subtype: String) -> String? { + guard let plusIndex = subtype.lastIndex(of: "+") else { return nil } + let suffixStart = subtype.index(after: plusIndex) + guard suffixStart < subtype.endIndex else { return nil } + return String(subtype[suffixStart...]) + } + + /// Returns the structured syntax suffix of a wildcard subtype, if present. + /// For example, returns `"json"` for `"*+json"`. + static func structuredSyntaxWildcardSuffix(of subtype: String) -> String? { + guard subtype.hasPrefix("*+") else { return nil } + let suffixStart = subtype.index(subtype.startIndex, offsetBy: 2) + guard suffixStart < subtype.endIndex else { return nil } + return String(subtype[suffixStart...]) + } + + /// Returns whether two lowercased subtypes are compatible. + /// Supports exact matches, same-type structured syntax suffix matches + /// in either direction, and wildcard structured syntax suffixes. + static func subtypesMatch(_ lhs: String, _ rhs: String) -> Bool { + if lhs == rhs { return true } + + if let lhsStructuredSyntaxWildcardSuffix = Self.structuredSyntaxWildcardSuffix(of: lhs) { + return rhs == lhsStructuredSyntaxWildcardSuffix + || Self.structuredSyntaxSuffix(of: rhs) == lhsStructuredSyntaxWildcardSuffix + } + if let rhsStructuredSyntaxWildcardSuffix = Self.structuredSyntaxWildcardSuffix(of: rhs) { + return lhs == rhsStructuredSyntaxWildcardSuffix + || Self.structuredSyntaxSuffix(of: lhs) == rhsStructuredSyntaxWildcardSuffix + } + + let lhsStructuredSyntaxSuffix = Self.structuredSyntaxSuffix(of: lhs) + let rhsStructuredSyntaxSuffix = Self.structuredSyntaxSuffix(of: rhs) + return lhsStructuredSyntaxSuffix == rhs || rhsStructuredSyntaxSuffix == lhs + || (lhsStructuredSyntaxSuffix != nil && lhsStructuredSyntaxSuffix == rhsStructuredSyntaxSuffix) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index 73f8fecb..9255b289 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -46,6 +46,8 @@ extension Converter { // either. return options[0] } + let receivedTypeLowercased = receivedType.lowercased() + let receivedSubtypeLowercased = receivedSubtype.lowercased() let evaluatedOptions = try options.map { stringOption in guard let parsedOption = OpenAPIMIMEType(stringOption) else { throw RuntimeError.invalidExpectedContentType(stringOption) @@ -56,10 +58,29 @@ extension Converter { receivedParameters: received.parameters, against: parsedOption ) - return (contentType: stringOption, match: match) + let isExactSubtypeMatch: Bool + if case let .concrete(type: optionType, subtype: optionSubtype) = parsedOption.kind { + isExactSubtypeMatch = + optionType.lowercased() == receivedTypeLowercased + && optionSubtype.lowercased() == receivedSubtypeLowercased + } else { + isExactSubtypeMatch = false + } + return (contentType: stringOption, match: match, isExactSubtypeMatch: isExactSubtypeMatch) + } + func rankingTuple( + _ option: (contentType: String, match: OpenAPIMIMEType.Match, isExactSubtypeMatch: Bool) + ) -> (Int, Int, Int) { + switch option.match { + case .incompatible: return (0, 0, 0) + case .wildcard: return (1, 0, 0) + case .subtypeWildcard: return (2, 0, 0) + case .typeAndSubtype(let matchedParameterCount): + return (3, option.isExactSubtypeMatch ? 1 : 0, matchedParameterCount) + } } // The force unwrap is safe, we only get here if the array is not empty. - let bestOption = evaluatedOptions.max { a, b in a.match.score < b.match.score }! + let bestOption = evaluatedOptions.max { a, b in rankingTuple(a) < rankingTuple(b) }! let bestContentType = bestOption.contentType if case .incompatible = bestOption.match { throw RuntimeError.unexpectedContentTypeHeader( diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index a3088bd3..3a38f006 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -44,6 +44,9 @@ extension Converter { /// - substring: Expected content type, for example "application/json". /// - headerFields: Header fields in which to look for "Accept". /// Also supports wildcars, such as "application/\*" and "\*/\*". + /// Additionally, supports matching structured syntax suffixes (RFC 6839), + /// for example `Accept: application/json` is treated as compatible with + /// `Content-Type: application/problem+json`. /// - Throws: An error if the "Accept" header is present but incompatible with the provided content type, /// or if there are issues parsing the header. public func validateAcceptIfPresent(_ substring: String, in headerFields: HTTPFields) throws { @@ -495,8 +498,13 @@ fileprivate extension OpenAPIMIMEType { 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() + let acceptTypeLowercased = acceptType.lowercased() + let substringTypeLowercased = substringType.lowercased() + guard acceptTypeLowercased == substringTypeLowercased else { return false } + + let acceptSubtypeLowercased = acceptSubtype.lowercased() + let substringSubtypeLowercased = substringSubtype.lowercased() + return Self.subtypesMatch(acceptSubtypeLowercased, substringSubtypeLowercased) } } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index a28ec47d..3fce710f 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -103,6 +103,8 @@ final class Test_OpenAPIMIMEType: Test_Runtime { let jsonWith2Params = OpenAPIMIMEType("application/json; charset=utf-8; version=1")! let jsonWith1Param = OpenAPIMIMEType("application/json; charset=utf-8")! let json = OpenAPIMIMEType("application/json")! + let problemJSON = OpenAPIMIMEType("application/problem+json")! + let anyJsonStructuredSyntax = OpenAPIMIMEType("application/*+json")! let fullWildcard = OpenAPIMIMEType("*/*")! let subtypeWildcard = OpenAPIMIMEType("application/*")! @@ -130,5 +132,48 @@ final class Test_OpenAPIMIMEType: Test_Runtime { testJSONWith2Params(against: json, expected: .typeAndSubtype(matchedParameterCount: 0)) testJSONWith2Params(against: subtypeWildcard, expected: .subtypeWildcard) testJSONWith2Params(against: fullWildcard, expected: .wildcard) + + testCase( + receivedType: "application", + receivedSubtype: "problem+json", + receivedParameters: [:], + against: json, + expected: .typeAndSubtype(matchedParameterCount: 0) + ) + testCase( + receivedType: "application", + receivedSubtype: "json", + receivedParameters: [:], + against: problemJSON, + expected: .typeAndSubtype(matchedParameterCount: 0) + ) + testCase( + receivedType: "application", + receivedSubtype: "json", + receivedParameters: [:], + against: anyJsonStructuredSyntax, + expected: .typeAndSubtype(matchedParameterCount: 0) + ) + testCase( + receivedType: "application", + receivedSubtype: "problem+json", + receivedParameters: [:], + against: anyJsonStructuredSyntax, + expected: .typeAndSubtype(matchedParameterCount: 0) + ) + testCase( + receivedType: "application", + receivedSubtype: "problem+xml", + receivedParameters: [:], + against: json, + expected: .incompatible(.subtype) + ) + testCase( + receivedType: "text", + receivedSubtype: "foo+json", + receivedParameters: [:], + against: json, + expected: .incompatible(.type) + ) } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 85d04f25..e7508d2c 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -82,6 +82,18 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase(received: "application/json; charset=utf-8; version=1", options: ["*/*"], expected: "*/*") try testCase(received: "image/png", options: ["image/*", "*/*"], expected: "image/*") + try testCase(received: "application/problem+json", options: ["application/json"], expected: "application/json") + try testCase(received: "application/json", options: ["application/problem+json"], expected: "application/problem+json") + try testCase( + received: "application/problem+json", + options: ["application/json", "application/problem+json"], + expected: "application/problem+json" + ) + try testCase( + received: "application/problem+json; charset=utf-8; version=1", + options: ["application/json", "application/json; charset=utf-8"], + expected: "application/json; charset=utf-8" + ) XCTAssertThrowsError( try testCase(received: "text/csv", options: ["text/html", "application/json"], expected: "-") ) { error in @@ -104,8 +116,11 @@ final class Test_CommonConverterExtensions: Test_Runtime { } try testCase(received: nil, match: "application/json") try testCase(received: "application/json", match: "application/json") + try testCase(received: "application/problem+json", match: "application/json") + try testCase(received: "application/json", match: "application/problem+json") try testCase(received: "application/json", match: "application/*") try testCase(received: "application/json", match: "*/*") + XCTAssertThrowsError(try testCase(received: "text/foo+json", match: "application/json")) } func testExtractContentDispositionNameAndFilename() throws { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 3d956bb2..cee119a1 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -35,6 +35,8 @@ final class Test_ServerConverterExtensions: Test_Runtime { let wildcard: HTTPFields = [.accept: "*/*"] let partialWildcard: HTTPFields = [.accept: "text/*"] let short: HTTPFields = [.accept: "text/plain"] + let json: HTTPFields = [.accept: "application/json"] + let anyJsonStructuredSyntax: HTTPFields = [.accept: "application/*+json"] let long: HTTPFields = [ .accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8" ] @@ -54,6 +56,14 @@ final class Test_ServerConverterExtensions: Test_Runtime { (short, "text/plain", true), (short, "application/json", false), (short, "application/*", false), (short, "*/*", false), + // RFC 6839 structured syntax suffix matching (common with RFC 7807 Problem Details): + // If response is application/problem+json, treat Accept: application/json as compatible. + (json, "application/problem+json", true), + (json, "application/json", true), + (anyJsonStructuredSyntax, "application/json", true), + (anyJsonStructuredSyntax, "application/problem+json", true), + (json, "text/foo+json", false), + // A bunch of acceptable content types (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), (long, "image/webp", true), (long, "application/json", true),