Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this right? Should text/foo+json not match application/json?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was intentional. I kept top-level type matching exact and only relaxed subtype matching via the structured suffix. My read of RFC 6839 is that +json enables generic JSON processing, but not necessarily treating different top-level media types as equivalent for negotiation. Since OpenAPI content types are still full media types, I scoped this to same-type cases like application/problem+json and application/json. If you think cross-type +json matching is desirable, I can expand it, but that felt like a larger behavior change than this fix.


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:
Expand Down Expand Up @@ -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)
}
}
25 changes: 23 additions & 2 deletions Sources/OpenAPIRuntime/Conversion/Converter+Common.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions Sources/OpenAPIRuntime/Conversion/Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
45 changes: 45 additions & 0 deletions Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add application/problem+json and then test that application/json is compatible with it in both directions? Not just when an explicit wildcard is used.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I only covered the structured-suffix -> base-JSON direction here. I’ll add explicit application/problem+json / application/json compatibility coverage in both directions, without relying on application/*+json.

let fullWildcard = OpenAPIMIMEType("*/*")!
let subtypeWildcard = OpenAPIMIMEType("application/*")!

Expand Down Expand Up @@ -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)
)
}
}
15 changes: 15 additions & 0 deletions Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand All @@ -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),
Expand Down