Skip to content

Commit

Permalink
[Runtime] Accept header (#37)
Browse files Browse the repository at this point in the history
[Runtime] Accept header

### Motivation

SOAR-0003 was accepted, this is the runtime side of the implementation.

### Modifications

- Introduced a new protocol `AcceptableProtocol`, which all the generated, operation-specific Accept enums conform to.
- Introduced a new struct `QualityValue`, which wraps the quality parameter of the content type. Since the precision is capped at 3 decimal places, the internal storage is in 1000's, allowing for more reliable comparisons, as floating point numbers are only used when serialized into headers.
- Introduced a new struct `AcceptHeaderContentType`, generic over `AcceptableProtocol`, which adds `QualityValue` to each generated Accept enum.
- Introduced new extensions on `Converter` that allow setting and getting the Accept header.

### Result

These are the requirements for apple/swift-openapi-generator#185.

### Test Plan

Added unit tests for both `QualityValue` and `AcceptHeaderContentType`, and for the new `Converter` methods.


Reviewed by: gjcairo

Builds:
     ✔︎ pull request validation (5.8) - Build finished. 
     ✔︎ pull request validation (5.9) - Build finished. 
     ✔︎ pull request validation (api breakage) - Build finished. 
     ✔︎ pull request validation (docc test) - Build finished. 
     ✔︎ pull request validation (integration test) - Build finished. 
     ✔︎ pull request validation (nightly) - Build finished. 
     ✔︎ pull request validation (soundness) - Build finished. 

#37
  • Loading branch information
czechboy0 authored Aug 24, 2023
1 parent 4312caf commit 24546b1
Show file tree
Hide file tree
Showing 9 changed files with 352 additions and 6 deletions.
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

0 comments on commit 24546b1

Please sign in to comment.