diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift new file mode 100644 index 00000000..d7386cb4 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -0,0 +1,173 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import Foundation + +/// A container for a parsed, valid MIME type. +@_spi(Generated) +public struct OpenAPIMIMEType: Equatable { + + /// The kind of the MIME type. + public enum Kind: Equatable { + + /// Any, spelled as `*/*`. + case any + + /// Any subtype of a concrete type, spelled as `type/*`. + case anySubtype(type: String) + + /// A concrete value, spelled as `type/subtype`. + case concrete(type: String, subtype: String) + + public static func == (lhs: Kind, rhs: Kind) -> Bool { + switch (lhs, rhs) { + case (.any, .any): + return true + case let (.anySubtype(lhsType), .anySubtype(rhsType)): + return lhsType.lowercased() == rhsType.lowercased() + case let (.concrete(lhsType, lhsSubtype), .concrete(rhsType, rhsSubtype)): + return lhsType.lowercased() == rhsType.lowercased() + && lhsSubtype.lowercased() == rhsSubtype.lowercased() + default: + return false + } + } + } + + /// The kind of the MIME type. + public var kind: Kind + + /// Any optional parameters. + public var parameters: [String: String] + + /// Creates a new MIME type. + /// - Parameters: + /// - kind: The kind of the MIME type. + /// - parameters: Any optional parameters. + public init(kind: Kind, parameters: [String: String] = [:]) { + self.kind = kind + self.parameters = parameters + } + + public static func == (lhs: OpenAPIMIMEType, rhs: OpenAPIMIMEType) -> Bool { + guard lhs.kind == rhs.kind else { + return false + } + // Parameter names are case-insensitive, parameter values are + // case-sensitive. + guard lhs.parameters.count == rhs.parameters.count else { + return false + } + if lhs.parameters.isEmpty { + return true + } + func normalizeKeyValue(key: String, value: String) -> (String, String) { + (key.lowercased(), value) + } + let normalizedLeftParams = Dictionary( + uniqueKeysWithValues: lhs.parameters.map(normalizeKeyValue) + ) + let normalizedRightParams = Dictionary( + uniqueKeysWithValues: rhs.parameters.map(normalizeKeyValue) + ) + return normalizedLeftParams == normalizedRightParams + } +} + +extension OpenAPIMIMEType.Kind: LosslessStringConvertible { + public init?(_ description: String) { + let typeAndSubtype = + description + .split(separator: "/") + .map(String.init) + guard typeAndSubtype.count == 2 else { + return nil + } + switch (typeAndSubtype[0], typeAndSubtype[1]) { + case ("*", let subtype): + guard subtype == "*" else { + return nil + } + self = .any + case (let type, "*"): + self = .anySubtype(type: type) + case (let type, let subtype): + self = .concrete(type: type, subtype: subtype) + } + } + + public var description: String { + switch self { + case .any: + return "*/*" + case .anySubtype(let type): + return "\(type)/*" + case .concrete(let type, let subtype): + return "\(type)/\(subtype)" + } + } +} + +extension OpenAPIMIMEType: LosslessStringConvertible { + public init?(_ description: String) { + var components = + description + // Split by semicolon + .split(separator: ";") + .map(String.init) + // Trim leading/trailing spaces + .map { $0.trimmingLeadingAndTrailingSpaces } + guard !components.isEmpty else { + return nil + } + let firstComponent = components.removeFirst() + guard let kind = OpenAPIMIMEType.Kind(firstComponent) else { + return nil + } + func parseParameter(_ string: String) -> (String, String)? { + let components = + string + .split(separator: "=") + .map(String.init) + guard components.count == 2 else { + return nil + } + return (components[0], components[1]) + } + let parameters = + components + .compactMap(parseParameter) + self.init( + kind: kind, + parameters: Dictionary( + parameters, + // Pick the first value when duplicate parameters are provided. + uniquingKeysWith: { a, _ in a } + ) + ) + } + + public var description: String { + ([kind.description] + + parameters + .sorted(by: { a, b in a.key < b.key }) + .map { "\($0)=\($1)" }) + .joined(separator: "; ") + } +} + +extension String { + fileprivate var trimmingLeadingAndTrailingSpaces: Self { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index 71034db3..373b8105 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -17,31 +17,55 @@ extension Converter { // MARK: Miscs - /// Validates that the Content-Type header field (if present) - /// is compatible with the provided content-type substring. + /// Returns the MIME type from the content-type header, if present. + /// - Parameter headerFields: The header fields to inspect for the content + /// type header. + /// - Returns: The content type value, or nil if not found or invalid. + public func extractContentTypeIfPresent(in headerFields: [HeaderField]) -> OpenAPIMIMEType? { + guard let rawValue = headerFields.firstValue(name: "content-type") else { + return nil + } + return OpenAPIMIMEType(rawValue) + } + + /// Checks whether a concrete content type matches an expected content type. /// - /// Succeeds if no Content-Type header is found in the response headers. + /// 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: - /// - headerFields: Header fields to inspect for a content type. - /// - substring: Expected content type. - /// - Throws: If the response's Content-Type value is not compatible with the provided substring. - public func validateContentTypeIfPresent( - in headerFields: [HeaderField], - substring: String - ) throws { - guard - let contentType = try getOptionalHeaderFieldAsText( - in: headerFields, - name: "content-type", - as: String.self - ) - else { - return + /// - 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 contentType.localizedCaseInsensitiveContains(substring) else { - throw RuntimeError.unexpectedContentTypeHeader(contentType) + 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() + } + } + + /// Returns an error to be thrown when an unexpected content type is + /// received. + /// - Parameter contentType: The content type that was received. + public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { + RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "") } // MARK: - Converter helper methods diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index a19b3d54..6ddef107 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -729,6 +729,29 @@ extension Converter { extension Converter { + /// Validates that the Content-Type header field (if present) + /// is compatible with the provided content-type substring. + /// + /// Succeeds if no Content-Type header is found in the response headers. + /// + /// - Parameters: + /// - headerFields: Header fields to inspect for a content type. + /// - substring: Expected content type. + /// - Throws: If the response's Content-Type value is not compatible with + /// the provided substring. + @available(*, deprecated, message: "Use isMatchingContentType instead.") + public func validateContentTypeIfPresent( + in headerFields: [HeaderField], + substring: String + ) throws { + guard let contentType = extractContentTypeIfPresent(in: headerFields) else { + return + } + guard try isMatchingContentType(received: contentType, expectedRaw: substring) else { + throw RuntimeError.unexpectedContentTypeHeader(contentType.description) + } + } + // | client | set | request body | text | string-convertible | optional | setOptionalRequestBodyAsText | @available(*, deprecated) public func setOptionalRequestBodyAsText( diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 1a2143b9..d8a72841 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -19,6 +19,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Miscs case invalidServerURL(String) + case invalidExpectedContentType(String) // Data conversion case failedToDecodeStringConvertibleValue(type: String) @@ -51,6 +52,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret switch self { case .invalidServerURL(let string): return "Invalid server URL: \(string)" + case .invalidExpectedContentType(let string): + return "Invalid expected content type: '\(string)'" case .failedToDecodeStringConvertibleValue(let string): return "Failed to decode a value of type '\(string)'." case .missingRequiredHeaderField(let name): diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift new file mode 100644 index 00000000..5aacd455 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import XCTest +@_spi(Generated) import OpenAPIRuntime + +final class Test_OpenAPIMIMEType: Test_Runtime { + func test() throws { + let cases: [(String, OpenAPIMIMEType?, String?)] = [ + + // Common + ( + "application/json", + OpenAPIMIMEType(kind: .concrete(type: "application", subtype: "json")), + "application/json" + ), + + // Subtype wildcard + ( + "application/*", + OpenAPIMIMEType(kind: .anySubtype(type: "application")), + "application/*" + ), + + // Type wildcard + ( + "*/*", + OpenAPIMIMEType(kind: .any), + "*/*" + ), + + // Common with a parameter + ( + "application/json; charset=UTF-8", + OpenAPIMIMEType( + kind: .concrete(type: "application", subtype: "json"), + parameters: [ + "charset": "UTF-8" + ] + ), + "application/json; charset=UTF-8" + ), + + // Common with two parameters + ( + "application/json; charset=UTF-8; boundary=1234", + OpenAPIMIMEType( + kind: .concrete(type: "application", subtype: "json"), + parameters: [ + "charset": "UTF-8", + "boundary": "1234", + ] + ), + "application/json; boundary=1234; charset=UTF-8" + ), + + // Common case preserving, but case insensitive equality + ( + "APPLICATION/JSON;CHARSET=UTF-8", + OpenAPIMIMEType( + kind: .concrete(type: "application", subtype: "json"), + parameters: [ + "charset": "UTF-8" + ] + ), + "APPLICATION/JSON; CHARSET=UTF-8" + ), + + // Invalid + ("application", nil, nil), + ("application/foo/bar", nil, nil), + ("", nil, nil), + ] + for (inputString, expectedMIME, outputString) in cases { + let mime = OpenAPIMIMEType(inputString) + XCTAssertEqual(mime, expectedMIME) + XCTAssertEqual(mime?.description, outputString) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 5eebceb4..27408e60 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -18,61 +18,33 @@ final class Test_CommonConverterExtensions: Test_Runtime { // MARK: Miscs - func testValidateContentType_match() throws { - let headerFields: [HeaderField] = [ - .init(name: "content-type", value: "application/json") - ] - XCTAssertNoThrow( - try converter.validateContentTypeIfPresent( - in: headerFields, - substring: "application/json" - ) - ) - } + func testContentTypeMatching() throws { + let cases: [(received: String, expected: String, isMatch: Bool)] = [ + ("application/json", "application/json", true), + ("APPLICATION/JSON", "application/json", true), + ("application/json", "application/*", true), + ("application/json", "*/*", true), + ("application/json", "text/*", false), + ("application/json", "application/xml", false), + ("application/json", "text/plain", false), - func testValidateContentType_match_substring() throws { - let headerFields: [HeaderField] = [ - .init(name: "content-type", value: "application/json; charset=utf-8") + ("text/plain; charset=UTF-8", "text/plain", true), + ("TEXT/PLAIN; CHARSET=UTF-8", "text/plain", true), + ("text/plain; charset=UTF-8", "text/*", true), + ("text/plain; charset=UTF-8", "*/*", true), + ("text/plain; charset=UTF-8", "application/*", false), + ("text/plain; charset=UTF-8", "text/html", false), ] - XCTAssertNoThrow( - try converter.validateContentTypeIfPresent( - in: headerFields, - substring: "application/json" - ) - ) - } - - func testValidateContentType_missing() throws { - let headerFields: [HeaderField] = [] - XCTAssertNoThrow( - try converter.validateContentTypeIfPresent( - in: headerFields, - substring: "application/json" + for testCase in cases { + XCTAssertEqual( + try converter.isMatchingContentType( + received: .init(testCase.received), + expectedRaw: testCase.expected + ), + testCase.isMatch, + "Wrong result for (\(testCase.received), \(testCase.expected), \(testCase.isMatch))" ) - ) - } - - func testValidateContentType_mismatch() throws { - let headerFields: [HeaderField] = [ - .init(name: "content-type", value: "text/plain") - ] - XCTAssertThrowsError( - try converter.validateContentTypeIfPresent( - in: headerFields, - substring: "application/json" - ), - "Was expected to throw error on mismatch", - { error in - guard - let err = error as? RuntimeError, - case .unexpectedContentTypeHeader(let contentType) = err - else { - XCTFail("Unexpected kind of error thrown") - return - } - XCTAssertEqual(contentType, "text/plain") - } - ) + } } // MARK: Converter helper methods diff --git a/Tests/OpenAPIRuntimeTests/Deprecated/Test_Deprecated.swift b/Tests/OpenAPIRuntimeTests/Deprecated/Test_Deprecated.swift index 3f1bdfa0..4faafbd8 100644 --- a/Tests/OpenAPIRuntimeTests/Deprecated/Test_Deprecated.swift +++ b/Tests/OpenAPIRuntimeTests/Deprecated/Test_Deprecated.swift @@ -222,6 +222,67 @@ final class Test_Deprecated: Test_Runtime { ) } + @available(*, deprecated) + func testValidateContentType_match() throws { + let headerFields: [HeaderField] = [ + .init(name: "content-type", value: "application/json") + ] + XCTAssertNoThrow( + try converter.validateContentTypeIfPresent( + in: headerFields, + substring: "application/json" + ) + ) + } + + @available(*, deprecated) + func testValidateContentType_match_substring() throws { + let headerFields: [HeaderField] = [ + .init(name: "content-type", value: "application/json; charset=utf-8") + ] + XCTAssertNoThrow( + try converter.validateContentTypeIfPresent( + in: headerFields, + substring: "application/json" + ) + ) + } + + @available(*, deprecated) + func testValidateContentType_missing() throws { + let headerFields: [HeaderField] = [] + XCTAssertNoThrow( + try converter.validateContentTypeIfPresent( + in: headerFields, + substring: "application/json" + ) + ) + } + + @available(*, deprecated) + func testValidateContentType_mismatch() throws { + let headerFields: [HeaderField] = [ + .init(name: "content-type", value: "text/plain") + ] + XCTAssertThrowsError( + try converter.validateContentTypeIfPresent( + in: headerFields, + substring: "application/json" + ), + "Was expected to throw error on mismatch", + { error in + guard + let err = error as? RuntimeError, + case .unexpectedContentTypeHeader(let contentType) = err + else { + XCTFail("Unexpected kind of error thrown") + return + } + XCTAssertEqual(contentType, "text/plain") + } + ) + } + // | server | set | response body | text | string-convertible | required | setResponseBodyAsText | @available(*, deprecated) func test_deprecated_setResponseBodyAsText_stringConvertible() throws {