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
162 changes: 162 additions & 0 deletions Sources/OpenAPIRuntime/Base/Acceptable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

/// The protocol that all generated `AcceptableContentType` enums conform to.
public protocol AcceptableProtocol: RawRepresentable, Sendable, Hashable, CaseIterable where RawValue == String {}

/// A quality value used to describe the order of priority in a comma-separated
/// list of values, such as in the Accept header.
public struct QualityValue: Sendable, Hashable {

/// As the quality value only retains up to and including 3 decimal digits,
/// we store it in terms of the thousands.
///
/// This allows predictable equality comparisons and sorting.
///
/// For example, 1000 thousands is the quality value of 1.0.
private let thousands: UInt16

/// Returns a Boolean value indicating whether the quality value is
/// at its default value 1.0.
public var isDefault: Bool {
thousands == 1000
}

/// Creates a new quality value from the provided floating-point number.
///
/// - Precondition: The value must be between 0.0 and 1.0, inclusive.
public init(doubleValue: Double) {
precondition(
doubleValue >= 0.0 && doubleValue <= 1.0,
"Provided quality number is out of range, must be between 0.0 and 1.0, inclusive."
)
self.thousands = UInt16(doubleValue * 1000)
}

/// The value represented as a floating-point number between 0.0 and 1.0, inclusive.
public var doubleValue: Double {
Double(thousands) / 1000
}
}

extension QualityValue: RawRepresentable {
public init?(rawValue: String) {
guard let doubleValue = Double(rawValue) else {
return nil
}
self.init(doubleValue: doubleValue)
}

public var rawValue: String {
String(format: "%0.3f", doubleValue)
}
}

extension QualityValue: ExpressibleByIntegerLiteral {
public init(integerLiteral value: UInt16) {
precondition(
value >= 0 && value <= 1,
"Provided quality number is out of range, must be between 0 and 1, inclusive."
)
self.thousands = value * 1000
}
}

extension QualityValue: ExpressibleByFloatLiteral {
public init(floatLiteral value: Double) {
self.init(doubleValue: value)
}
}

extension Array {

/// Returns the default values for the acceptable type.
public static func defaultValues<T: AcceptableProtocol>() -> [AcceptHeaderContentType<T>]
where Element == AcceptHeaderContentType<T> {
T.allCases.map { .init(contentType: $0) }
}
}

/// A wrapper of an individual content type in the accept header.
public struct AcceptHeaderContentType<ContentType: AcceptableProtocol>: Sendable, Hashable {

/// The value representing the content type.
public var contentType: ContentType

/// The quality value of this content type.
///
/// Used to describe the order of priority in a comma-separated
/// list of values.
///
/// Content types with a higher priority should be preferred by the server
/// when deciding which content type to use in the response.
///
/// Also called the "q-factor" or "q-value".
public var quality: QualityValue

/// Creates a new content type from the provided parameters.
/// - Parameters:
/// - value: The value representing the content type.
/// - quality: The quality of the content type, between 0.0 and 1.0.
/// - Precondition: Quality must be in the range 0.0 and 1.0 inclusive.
public init(contentType: ContentType, quality: QualityValue = 1.0) {
self.quality = quality
self.contentType = contentType
}

/// Returns the default set of acceptable content types for this type, in
/// the order specified in the OpenAPI document.
public static var defaultValues: [Self] {
ContentType.allCases.map { .init(contentType: $0) }
}
}

extension AcceptHeaderContentType: RawRepresentable {
public init?(rawValue: String) {
guard let validMimeType = OpenAPIMIMEType(rawValue) else {
// Invalid MIME type.
return nil
}
let quality: QualityValue
if let rawQuality = validMimeType.parameters["q"] {
guard let parsedQuality = QualityValue(rawValue: rawQuality) else {
// Invalid quality parameter.
return nil
}
quality = parsedQuality
} else {
quality = 1.0
}
guard let typeAndSubtype = ContentType(rawValue: validMimeType.kind.description.lowercased()) else {
// Invalid type/subtype.
return nil
}
self.init(contentType: typeAndSubtype, quality: quality)
}

public var rawValue: String {
contentType.rawValue + (quality.isDefault ? "" : "; q=\(quality.rawValue)")
}
}

extension Array {

/// Returns the array sorted by the quality value, highest quality first.
public func sortedByQuality<T: AcceptableProtocol>() -> [AcceptHeaderContentType<T>]
where Element == AcceptHeaderContentType<T> {
sorted { a, b in
a.quality.doubleValue > b.quality.doubleValue
}
}
}
6 changes: 0 additions & 6 deletions Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,3 @@ extension OpenAPIMIMEType: LosslessStringConvertible {
.joined(separator: "; ")
}
}

extension String {
fileprivate var trimmingLeadingAndTrailingSpaces: Self {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
16 changes: 16 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/Converter+Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ import Foundation

extension Converter {

/// Sets the "accept" header according to the provided content types.
/// - Parameters:
/// - headerFields: The header fields where to add the "accept" header.
/// - contentTypes: The array of acceptable content types by the client.
public func setAcceptHeader<T: AcceptableProtocol>(
in headerFields: inout [HeaderField],
contentTypes: [AcceptHeaderContentType<T>]
) {
headerFields.append(
.init(
name: "accept",
value: contentTypes.map(\.rawValue).joined(separator: ", ")
)
)
}

// | client | set | request path | text | string-convertible | required | renderedRequestPath |
public func renderedRequestPath(
template: String,
Expand Down
25 changes: 25 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ public extension Converter {

// MARK: Miscs

/// Returns the "accept" header parsed into individual content types.
/// - Parameter headerFields: The header fields to inspect for an "accept"
/// header.
/// - Returns: The parsed content types, or the default content types if
/// the header was not provided.
func extractAcceptHeaderIfPresent<T: AcceptableProtocol>(
in headerFields: [HeaderField]
) throws -> [AcceptHeaderContentType<T>] {
guard let rawValue = headerFields.firstValue(name: "accept") else {
return AcceptHeaderContentType<T>.defaultValues
}
let rawComponents =
rawValue
.split(separator: ",")
.map(String.init)
.map(\.trimmingLeadingAndTrailingSpaces)
let parsedComponents = try rawComponents.map { rawComponent in
guard let value = AcceptHeaderContentType<T>(rawValue: rawComponent) else {
throw RuntimeError.malformedAcceptHeader(rawComponent)
}
return value
}
return parsedComponents
}

/// Validates that the Accept header in the provided response
/// is compatible with the provided content type substring.
/// - Parameters:
Expand Down
9 changes: 9 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,12 @@ extension URLComponents {
queryItems = groups.otherItems + [newItem]
}
}

extension String {

/// Returns the string with leading and trailing whitespace (such as spaces
/// and newlines) removed.
var trimmingLeadingAndTrailingSpaces: Self {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
3 changes: 3 additions & 0 deletions Sources/OpenAPIRuntime/Errors/RuntimeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
case missingRequiredHeaderField(String)
case unexpectedContentTypeHeader(String)
case unexpectedAcceptHeader(String)
case malformedAcceptHeader(String)

// Path
case missingRequiredPathParameter(String)
Expand Down Expand Up @@ -74,6 +75,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
return "Unexpected Content-Type header: \(contentType)"
case .unexpectedAcceptHeader(let accept):
return "Unexpected Accept header: \(accept)"
case .malformedAcceptHeader(let accept):
return "Malformed Accept header: \(accept)"
case .missingRequiredPathParameter(let name):
return "Missing required path parameter named: \(name)"
case .missingRequiredQueryParameter(let name):
Expand Down
107 changes: 107 additions & 0 deletions Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//===----------------------------------------------------------------------===//
//
// 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

enum TestAcceptable: AcceptableProtocol {
case json
case other(String)

init?(rawValue: String) {
switch rawValue {
case "application/json":
self = .json
default:
self = .other(rawValue)
}
}

var rawValue: String {
switch self {
case .json:
return "application/json"
case .other(let string):
return string
}
}

static var allCases: [TestAcceptable] {
[.json]
}
}

final class Test_AcceptHeaderContentType: Test_Runtime {
func test() throws {
do {
let contentType = AcceptHeaderContentType(contentType: TestAcceptable.json)
XCTAssertEqual(contentType.contentType, .json)
XCTAssertEqual(contentType.quality, 1.0)
XCTAssertEqual(contentType.rawValue, "application/json")
XCTAssertEqual(
AcceptHeaderContentType<TestAcceptable>(rawValue: "application/json"),
contentType
)
}
do {
let contentType = AcceptHeaderContentType(
contentType: TestAcceptable.json,
quality: 0.5
)
XCTAssertEqual(contentType.contentType, .json)
XCTAssertEqual(contentType.quality, 0.5)
XCTAssertEqual(contentType.rawValue, "application/json; q=0.500")
XCTAssertEqual(
AcceptHeaderContentType<TestAcceptable>(rawValue: "application/json; q=0.500"),
contentType
)
}
do {
XCTAssertEqual(
AcceptHeaderContentType<TestAcceptable>.defaultValues,
[
.init(contentType: .json)
]
)
}
do {
let unsorted: [AcceptHeaderContentType<TestAcceptable>] = [
.init(contentType: .other("*/*"), quality: 0.3),
.init(contentType: .json, quality: 0.5),
]
XCTAssertEqual(
unsorted.sortedByQuality(),
[
.init(contentType: .json, quality: 0.5),
.init(contentType: .other("*/*"), quality: 0.3),
]
)
}
}
}

final class Test_QualityValue: Test_Runtime {
func test() {
XCTAssertEqual((1 as QualityValue).doubleValue, 1.0)
XCTAssertTrue((1 as QualityValue).isDefault)
XCTAssertFalse(QualityValue(doubleValue: 0.5).isDefault)
XCTAssertEqual(QualityValue(doubleValue: 0.5).doubleValue, 0.5)
XCTAssertEqual(QualityValue(floatLiteral: 0.5).doubleValue, 0.5)
XCTAssertEqual(QualityValue(integerLiteral: 0).doubleValue, 0)
XCTAssertEqual(QualityValue(rawValue: "1.0")?.doubleValue, 1.0)
XCTAssertEqual(QualityValue(rawValue: "0.0")?.doubleValue, 0.0)
XCTAssertEqual(QualityValue(rawValue: "0.3")?.doubleValue, 0.3)
XCTAssertEqual(QualityValue(rawValue: "0.54321")?.rawValue, "0.543")
XCTAssertNil(QualityValue(rawValue: "hi"))
}
}
14 changes: 14 additions & 0 deletions Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ import XCTest

final class Test_ClientConverterExtensions: Test_Runtime {

func test_setAcceptHeader() throws {
var headerFields: [HeaderField] = []
converter.setAcceptHeader(
in: &headerFields,
contentTypes: [.init(contentType: TestAcceptable.json, quality: 0.8)]
)
XCTAssertEqual(
headerFields,
[
.init(name: "accept", value: "application/json; q=0.800")
]
)
}

// MARK: Converter helper methods

// | client | set | request path | text | string-convertible | required | renderedRequestPath |
Expand Down
Loading