Skip to content

[Runtime] Multiple content types support #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f997ab1
[WIP] Multiple content types
czechboy0 Jul 27, 2023
66b78af
Try designing EncodedBodyContent away
czechboy0 Jul 28, 2023
00e9b40
Deprecate unit tests that call deprecated methods
czechboy0 Jul 28, 2023
f6c7f23
Stop using EncodableBodyContent in responses as well
czechboy0 Jul 28, 2023
59f199f
Update tests as well
czechboy0 Jul 28, 2023
25049e8
Move a function for a smaller PR diff
czechboy0 Jul 31, 2023
c384fc8
Merge branch 'main' into hd-draft-multiple-content-types
czechboy0 Aug 1, 2023
0ec13e5
Merge branch 'main' into hd-draft-multiple-content-types
czechboy0 Aug 1, 2023
a882dc8
Merge branch 'main' into hd-draft-multiple-content-types
czechboy0 Aug 3, 2023
ddc431d
Update Sources/OpenAPIRuntime/Conversion/Converter+Common.swift
czechboy0 Aug 3, 2023
efc85c2
PR feedback: Introduce a MIMEType wrapper type
czechboy0 Aug 3, 2023
7f832ac
Fix formatting
czechboy0 Aug 3, 2023
606aa0e
PR feedback: s/isValidContentType/isMatchingContentType
czechboy0 Aug 3, 2023
5752e18
PR feedback: no need to prefix fileprivate extensions
czechboy0 Aug 3, 2023
de2248b
PR feedback: restructure MIMEType to disallow json/*
czechboy0 Aug 3, 2023
09b68a7
PR feedback: rename MIMEType to OpenAPIMIMEType
czechboy0 Aug 3, 2023
6df8431
PR feedback: Add a comment about how we're handling duplicate paramet…
czechboy0 Aug 3, 2023
e0dae85
PR feedback: Make isMatchingContentType stricter + rename expected to…
czechboy0 Aug 3, 2023
f4678b2
Only allocate dictionaries if there are parameters
czechboy0 Aug 3, 2023
63f44b0
Merge branch 'main' into hd-draft-multiple-content-types
czechboy0 Aug 4, 2023
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
173 changes: 173 additions & 0 deletions Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
64 changes: 44 additions & 20 deletions Sources/OpenAPIRuntime/Conversion/Converter+Common.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Sources/OpenAPIRuntime/Deprecated/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: _StringConvertible, C>(
Expand Down
3 changes: 3 additions & 0 deletions Sources/OpenAPIRuntime/Errors/RuntimeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret

// Miscs
case invalidServerURL(String)
case invalidExpectedContentType(String)

// Data conversion
case failedToDecodeStringConvertibleValue(type: String)
Expand Down Expand Up @@ -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):
Expand Down
90 changes: 90 additions & 0 deletions Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading