diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 7766aa2f..95390a7f 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -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) + } + } +} diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index 1123d1ff..a9c27f9c 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -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 diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index ff41be62..20b7a76a 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -22,16 +22,19 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. static func failedToDecodeAnySchema( type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( codingPath: codingPath, - debugDescription: "The anyOf structure did not decode into any child schema." + debugDescription: "The anyOf structure did not decode into any child schema.", + underlyingError: MultiError(errors: errors) ) ) } @@ -43,24 +46,47 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. @_spi(Generated) public static func failedToDecodeOneOfSchema( type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( codingPath: codingPath, - debugDescription: "The oneOf structure did not decode into any child schema." + debugDescription: "The oneOf structure did not decode into any child schema.", + underlyingError: MultiError(errors: errors) ) ) } -} -@_spi(Generated) -extension DecodingError { + /// Returns a decoding error used by the oneOf decoder when + /// the discriminator property contains an unknown schema name. + /// - Parameters: + /// - discriminatorKey: The discriminator coding key. + /// - discriminatorValue: The unknown value of the discriminator. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type, with the discriminator value as the last component. + /// - Returns: A decoding error. + @_spi(Generated) + public static func unknownOneOfDiscriminator( + discriminatorKey: any CodingKey, + discriminatorValue: String, + codingPath: [any CodingKey] + ) -> Self { + return DecodingError.keyNotFound( + discriminatorKey, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: + "The oneOf structure does not contain the provided discriminator value '\(discriminatorValue)'." + ) + ) + } /// Verifies that the anyOf decoder successfully decoded at least one /// child schema, and throws an error otherwise. @@ -70,17 +96,49 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. + @_spi(Generated) public static func verifyAtLeastOneSchemaIsNotNil( _ values: [Any?], type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) throws { guard values.contains(where: { $0 != nil }) else { throw DecodingError.failedToDecodeAnySchema( type: type, - codingPath: codingPath + codingPath: codingPath, + errors: errors ) } } } + +/// A wrapper of multiple errors, for example collected during a parallelized +/// operation from the individual subtasks. +struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible { + + /// The multiple underlying errors. + var errors: [any Error] + + var description: String { + let combinedDescription = + errors + .map { error in + guard let error = error as? (any PrettyStringConvertible) else { + return error.localizedDescription + } + return error.prettyDescription + } + .enumerated() + .map { ($0.offset + 1, $0.element) } + .map { "Error \($0.0): [\($0.1)]" } + .joined(separator: ", ") + return "MultiError (contains \(errors.count) error\(errors.count == 1 ? "" : "s")): \(combinedDescription)" + } + + var errorDescription: String? { + description + } +} diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index bf454112..5de87792 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -98,3 +98,119 @@ 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() + } + } +} + +extension DecodingError { + /// Returns a decoding error used by the oneOf decoder when not a single + /// child schema decodes the received payload. + /// - Parameters: + /// - type: The type representing the oneOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Returns: A decoding error. + @_spi(Generated) + @available(*, deprecated) + public static func failedToDecodeOneOfSchema( + type: Any.Type, + codingPath: [any CodingKey] + ) -> Self { + DecodingError.valueNotFound( + type, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "The oneOf structure did not decode into any child schema." + ) + ) + } + + /// Returns a decoding error used by the anyOf decoder when not a single + /// child schema decodes the received payload. + /// - Parameters: + /// - type: The type representing the anyOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Returns: A decoding error. + @available(*, deprecated) + static func failedToDecodeAnySchema( + type: Any.Type, + codingPath: [any CodingKey] + ) -> Self { + DecodingError.valueNotFound( + type, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "The anyOf structure did not decode into any child schema." + ) + ) + } + + /// Verifies that the anyOf decoder successfully decoded at least one + /// child schema, and throws an error otherwise. + /// - Parameters: + /// - values: An array of optional values to check. + /// - type: The type representing the anyOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. + @_spi(Generated) + @available(*, deprecated) + public static func verifyAtLeastOneSchemaIsNotNil( + _ values: [Any?], + type: Any.Type, + codingPath: [any CodingKey] + ) throws { + guard values.contains(where: { $0 != nil }) else { + throw DecodingError.failedToDecodeAnySchema( + type: type, + codingPath: codingPath + ) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index 5aacd455..3fbbf97d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -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 @@ -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 + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index cb5bd055..4819e912 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -25,6 +25,7 @@ final class Test_CommonConverterExtensions: Test_Runtime { // MARK: Miscs + @available(*, deprecated) func testContentTypeMatching() throws { let cases: [(received: String, expected: String, isMatch: Bool)] = [ ("application/json", "application/json", true), @@ -54,6 +55,153 @@ final class Test_CommonConverterExtensions: Test_Runtime { } } + func testBestContentType() throws { + func testCase( + received: String?, + options: [String], + expected expectedChoice: String, + file: StaticString = #file, + line: UInt = #line + ) throws { + let choice = try converter.bestContentType( + received: received.map { .init($0)! }, + options: options + ) + XCTAssertEqual(choice, expectedChoice, file: file, line: line) + } + + try testCase( + received: nil, + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + try testCase( + received: "*/*", + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + try testCase( + received: "application/*", + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + XCTAssertThrowsError( + try testCase( + received: "application/json", + options: [ + "whoops" + ], + expected: "-" + ) + ) + XCTAssertThrowsError( + try testCase( + received: "application/json", + options: [ + "text/plain", + "image/*", + ], + expected: "-" + ) + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + "application/json", + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + ], + expected: "application/json; charset=utf-8; version=1" + ) + try testCase( + received: "application/json; version=1; CHARSET=utf-8", + options: [ + "*/*", + "application/*", + "application/json", + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + ], + expected: "application/json; charset=utf-8; version=1" + ) + try testCase( + received: "application/json", + options: [ + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + "*/*", + "application/*", + "application/json", + ], + expected: "application/json" + ) + try testCase( + received: "application/json; charset=utf-8", + options: [ + "application/json; charset=utf-8; version=1", + "*/*", + "application/*", + "application/json", + ], + expected: "application/json" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + "application/json; charset=utf-8", + "application/json", + ], + expected: "application/json; charset=utf-8" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + ], + expected: "application/*" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*" + ], + expected: "*/*" + ) + + try testCase( + received: "image/png", + options: [ + "image/*", + "*/*", + ], + expected: "image/*" + ) + XCTAssertThrowsError( + try testCase( + received: "text/csv", + options: [ + "text/html", + "application/json", + ], + expected: "-" + ) + ) + } + // MARK: Converter helper methods // | common | set | header field | URI | both | setHeaderFieldAsURI | diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index fcce3775..44c62520 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -50,22 +50,30 @@ final class Test_URICodingRoundtrip: Test_Runtime { self.value3 = value3 } init(from decoder: any Decoder) throws { + var errors: [any Error] = [] do { let container = try decoder.singleValueContainer() - value1 = try? container.decode(Foundation.Date.self) + value1 = try container.decode(Foundation.Date.self) + } catch { + errors.append(error) } do { let container = try decoder.singleValueContainer() - value2 = try? container.decode(SimpleEnum.self) + value2 = try container.decode(SimpleEnum.self) + } catch { + errors.append(error) } do { let container = try decoder.singleValueContainer() - value3 = try? container.decode(TrivialStruct.self) + value3 = try container.decode(TrivialStruct.self) + } catch { + errors.append(error) } try DecodingError.verifyAtLeastOneSchemaIsNotNil( [value1, value2, value3], type: Self.self, - codingPath: decoder.codingPath + codingPath: decoder.codingPath, + errors: errors ) } func encode(to encoder: any Encoder) throws { diff --git a/docker/docker-compose.2204.main.yaml b/docker/docker-compose.2204.main.yaml index c40ac1ac..75b44630 100644 --- a/docker/docker-compose.2204.main.yaml +++ b/docker/docker-compose.2204.main.yaml @@ -11,7 +11,8 @@ services: test: image: *image environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors + # Disable warnings as errors on nightlies as they are still in-development. + # - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete