Skip to content
Merged
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
112 changes: 112 additions & 0 deletions Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,115 @@ extension OpenAPIMIMEType: LosslessStringConvertible {
.joined(separator: "; ")
}
}

// MARK: - Internals

extension OpenAPIMIMEType {

/// The result of a match evaluation between two MIME types.
enum Match: Hashable {

/// The reason why two types are incompatible.
enum IncompatibilityReason: Hashable {

/// The types don't match.
case type

/// The subtypes don't match.
case subtype

/// The parameter of the provided name is missing or doesn't match.
case parameter(name: String)
}

/// The types are incompatible for the provided reason.
case incompatible(IncompatibilityReason)

/// The types match based on a full wildcard `*/*`.
case wildcard

/// The types match based on a subtype wildcard, such as `image/*`.
case subtypeWildcard

/// The types match across the type, subtype, and the provided number
/// of parameters.
case typeAndSubtype(matchedParameterCount: Int)

/// A numeric representation of the quality of the match, the higher
/// the closer the types are.
var score: Int {
switch self {
case .incompatible:
return 0
case .wildcard:
return 1
case .subtypeWildcard:
return 2
case .typeAndSubtype(let matchedParameterCount):
return 3 + matchedParameterCount
}
}
}

/// Computes whether two MIME types match.
/// - Parameters:
/// - receivedType: The type component of the received MIME type.
/// - receivedSubtype: The subtype component of the received MIME type.
/// - receivedParameters: The parameters of the received MIME type.
/// - option: The MIME type to match against.
/// - Returns: The match result.
static func evaluate(
receivedType: String,
receivedSubtype: String,
receivedParameters: [String: String],
against option: OpenAPIMIMEType
) -> Match {
switch option.kind {
case .any:
return .wildcard
case .anySubtype(let expectedType):
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)
}

// A full concrete match, so also check parameters.
// The rule is:
// 1. If a received parameter is not found in the option,
// that's okay and gets ignored.
// 2. If an option parameter is not received, this is an
// incompatible content type match.
// This means we can just iterate over option parameters and
// check them against the received parameters, but we can
// ignore any received parameters that didn't appear in the
// option parameters.

// According to RFC 2045: https://www.rfc-editor.org/rfc/rfc2045#section-5.1
// "Type, subtype, and parameter names are case-insensitive."
// Inferred: Parameter values are case-sensitive.

let receivedNormalizedParameters = Dictionary(
uniqueKeysWithValues: receivedParameters.map { ($0.key.lowercased(), $0.value) }
)
var matchedParameterCount = 0
for optionParameter in option.parameters {
let normalizedParameterName = optionParameter.key.lowercased()
guard
let receivedValue = receivedNormalizedParameters[normalizedParameterName],
receivedValue == optionParameter.value
else {
return .incompatible(.parameter(name: normalizedParameterName))
}
matchedParameterCount += 1
}
return .typeAndSubtype(matchedParameterCount: matchedParameterCount)
}
}
}
76 changes: 41 additions & 35 deletions Sources/OpenAPIRuntime/Conversion/Converter+Common.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,45 +29,51 @@ extension Converter {
return OpenAPIMIMEType(rawValue)
}

/// Checks whether a concrete content type matches an expected content type.
///
/// The concrete content type can contain parameters, such as `charset`, but
/// they are ignored in the equality comparison.
///
/// The expected content type can contain wildcards, such as */* and text/*.
/// Chooses the most appropriate content type for the provided received
/// content type and a list of options.
/// - Parameters:
/// - received: The concrete content type to validate against the other.
/// - expectedRaw: The expected content type, can contain wildcards.
/// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type.
/// - Returns: A Boolean value representing whether the concrete content
/// type matches the expected one.
public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool {
guard let received else {
return false
}
guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else {
return false
/// - received: The received content type.
/// - options: The options to match against.
/// - Returns: The most appropriate option.
/// - Throws: If none of the options match the received content type.
/// - Precondition: `options` must not be empty.
public func bestContentType(
received: OpenAPIMIMEType?,
options: [String]
) throws -> String {
precondition(!options.isEmpty, "bestContentType options must not be empty.")
guard
let received,
case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind
else {
// If none received or if we received a wildcard, use the first one.
// This behavior isn't well defined by the OpenAPI specification.
// Note: We treat a partial wildcard, like `image/*` as a full
// wildcard `*/*`, but that's okay because for a concrete received
// content type the behavior of a wildcard is not clearly defined
// either.
return options[0]
}
guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else {
throw RuntimeError.invalidExpectedContentType(expectedRaw)
let evaluatedOptions = try options.map { stringOption in
guard let parsedOption = OpenAPIMIMEType(stringOption) else {
throw RuntimeError.invalidExpectedContentType(stringOption)
}
let match = OpenAPIMIMEType.evaluate(
receivedType: receivedType,
receivedSubtype: receivedSubtype,
receivedParameters: received.parameters,
against: parsedOption
)
return (contentType: stringOption, match: match)
}
switch expectedContentType.kind {
case .any:
return true
case .anySubtype(let expectedType):
return receivedType.lowercased() == expectedType.lowercased()
case .concrete(let expectedType, let expectedSubtype):
return receivedType.lowercased() == expectedType.lowercased()
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
let bestOption = evaluatedOptions.max { a, b in
a.match.score < b.match.score
}! // Safe, we only get here if the array is not empty.
let bestContentType = bestOption.contentType
if case .incompatible = bestOption.match {
throw RuntimeError.unexpectedContentTypeHeader(bestContentType)
}
}

/// Returns an error to be thrown when an unexpected content type is
/// received.
/// - Parameter contentType: The content type that was received.
/// - Returns: An error representing an unexpected content type.
public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error {
RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "")
return bestContentType
}

// MARK: - Converter helper methods
Expand Down
45 changes: 45 additions & 0 deletions Sources/OpenAPIRuntime/Deprecated/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,48 @@ extension ServerError {
)
}
}

extension Converter {
/// Returns an error to be thrown when an unexpected content type is
/// received.
/// - Parameter contentType: The content type that was received.
/// - Returns: An error representing an unexpected content type.
@available(*, deprecated)
public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error {
RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "")
}

/// Checks whether a concrete content type matches an expected content type.
///
/// The concrete content type can contain parameters, such as `charset`, but
/// they are ignored in the equality comparison.
///
/// The expected content type can contain wildcards, such as */* and text/*.
/// - Parameters:
/// - received: The concrete content type to validate against the other.
/// - expectedRaw: The expected content type, can contain wildcards.
/// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type.
/// - Returns: A Boolean value representing whether the concrete content
/// type matches the expected one.
@available(*, deprecated)
public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool {
guard let received else {
return false
}
guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else {
return false
}
guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else {
throw RuntimeError.invalidExpectedContentType(expectedRaw)
}
switch expectedContentType.kind {
case .any:
return true
case .anySubtype(let expectedType):
return receivedType.lowercased() == expectedType.lowercased()
case .concrete(let expectedType, let expectedSubtype):
return receivedType.lowercased() == expectedType.lowercased()
&& receivedSubtype.lowercased() == expectedSubtype.lowercased()
}
}
}
92 changes: 90 additions & 2 deletions Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
//
//===----------------------------------------------------------------------===//
import XCTest
@_spi(Generated) import OpenAPIRuntime
@_spi(Generated) @testable import OpenAPIRuntime

final class Test_OpenAPIMIMEType: Test_Runtime {
func test() throws {
func testParsing() throws {
let cases: [(String, OpenAPIMIMEType?, String?)] = [

// Common
Expand Down Expand Up @@ -87,4 +87,92 @@ final class Test_OpenAPIMIMEType: Test_Runtime {
XCTAssertEqual(mime?.description, outputString)
}
}

func testScore() throws {
let cases: [(OpenAPIMIMEType.Match, Int)] = [

(.incompatible(.type), 0),
(.incompatible(.subtype), 0),
(.incompatible(.parameter(name: "foo")), 0),

(.wildcard, 1),

(.subtypeWildcard, 2),

(.typeAndSubtype(matchedParameterCount: 0), 3),
(.typeAndSubtype(matchedParameterCount: 2), 5),
]
for (match, score) in cases {
XCTAssertEqual(match.score, score, "Mismatch for match: \(match)")
}
}

func testEvaluate() throws {
func testCase(
receivedType: String,
receivedSubtype: String,
receivedParameters: [String: String],
against option: OpenAPIMIMEType,
expected expectedMatch: OpenAPIMIMEType.Match,
file: StaticString = #file,
line: UInt = #line
) {
let result = OpenAPIMIMEType.evaluate(
receivedType: receivedType,
receivedSubtype: receivedSubtype,
receivedParameters: receivedParameters,
against: option
)
XCTAssertEqual(result, expectedMatch, file: file, line: line)
}

let jsonWith2Params = OpenAPIMIMEType("application/json; charset=utf-8; version=1")!
let jsonWith1Param = OpenAPIMIMEType("application/json; charset=utf-8")!
let json = OpenAPIMIMEType("application/json")!
let fullWildcard = OpenAPIMIMEType("*/*")!
let subtypeWildcard = OpenAPIMIMEType("application/*")!

func testJSONWith2Params(
against option: OpenAPIMIMEType,
expected expectedMatch: OpenAPIMIMEType.Match,
file: StaticString = #file,
line: UInt = #line
) {
testCase(
receivedType: "application",
receivedSubtype: "json",
receivedParameters: [
"charset": "utf-8",
"version": "1",
],
against: option,
expected: expectedMatch,
file: file,
line: line
)
}

// Actual test cases start here.

testJSONWith2Params(
against: jsonWith2Params,
expected: .typeAndSubtype(matchedParameterCount: 2)
)
testJSONWith2Params(
against: jsonWith1Param,
expected: .typeAndSubtype(matchedParameterCount: 1)
)
testJSONWith2Params(
against: json,
expected: .typeAndSubtype(matchedParameterCount: 0)
)
testJSONWith2Params(
against: subtypeWildcard,
expected: .subtypeWildcard
)
testJSONWith2Params(
against: fullWildcard,
expected: .wildcard
)
}
}
Loading