Skip to content

Commit 24546b1

Browse files
authored
[Runtime] Accept header (#37)
[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
1 parent 4312caf commit 24546b1

File tree

9 files changed

+352
-6
lines changed

9 files changed

+352
-6
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// The protocol that all generated `AcceptableContentType` enums conform to.
16+
public protocol AcceptableProtocol: RawRepresentable, Sendable, Hashable, CaseIterable where RawValue == String {}
17+
18+
/// A quality value used to describe the order of priority in a comma-separated
19+
/// list of values, such as in the Accept header.
20+
public struct QualityValue: Sendable, Hashable {
21+
22+
/// As the quality value only retains up to and including 3 decimal digits,
23+
/// we store it in terms of the thousands.
24+
///
25+
/// This allows predictable equality comparisons and sorting.
26+
///
27+
/// For example, 1000 thousands is the quality value of 1.0.
28+
private let thousands: UInt16
29+
30+
/// Returns a Boolean value indicating whether the quality value is
31+
/// at its default value 1.0.
32+
public var isDefault: Bool {
33+
thousands == 1000
34+
}
35+
36+
/// Creates a new quality value from the provided floating-point number.
37+
///
38+
/// - Precondition: The value must be between 0.0 and 1.0, inclusive.
39+
public init(doubleValue: Double) {
40+
precondition(
41+
doubleValue >= 0.0 && doubleValue <= 1.0,
42+
"Provided quality number is out of range, must be between 0.0 and 1.0, inclusive."
43+
)
44+
self.thousands = UInt16(doubleValue * 1000)
45+
}
46+
47+
/// The value represented as a floating-point number between 0.0 and 1.0, inclusive.
48+
public var doubleValue: Double {
49+
Double(thousands) / 1000
50+
}
51+
}
52+
53+
extension QualityValue: RawRepresentable {
54+
public init?(rawValue: String) {
55+
guard let doubleValue = Double(rawValue) else {
56+
return nil
57+
}
58+
self.init(doubleValue: doubleValue)
59+
}
60+
61+
public var rawValue: String {
62+
String(format: "%0.3f", doubleValue)
63+
}
64+
}
65+
66+
extension QualityValue: ExpressibleByIntegerLiteral {
67+
public init(integerLiteral value: UInt16) {
68+
precondition(
69+
value >= 0 && value <= 1,
70+
"Provided quality number is out of range, must be between 0 and 1, inclusive."
71+
)
72+
self.thousands = value * 1000
73+
}
74+
}
75+
76+
extension QualityValue: ExpressibleByFloatLiteral {
77+
public init(floatLiteral value: Double) {
78+
self.init(doubleValue: value)
79+
}
80+
}
81+
82+
extension Array {
83+
84+
/// Returns the default values for the acceptable type.
85+
public static func defaultValues<T: AcceptableProtocol>() -> [AcceptHeaderContentType<T>]
86+
where Element == AcceptHeaderContentType<T> {
87+
T.allCases.map { .init(contentType: $0) }
88+
}
89+
}
90+
91+
/// A wrapper of an individual content type in the accept header.
92+
public struct AcceptHeaderContentType<ContentType: AcceptableProtocol>: Sendable, Hashable {
93+
94+
/// The value representing the content type.
95+
public var contentType: ContentType
96+
97+
/// The quality value of this content type.
98+
///
99+
/// Used to describe the order of priority in a comma-separated
100+
/// list of values.
101+
///
102+
/// Content types with a higher priority should be preferred by the server
103+
/// when deciding which content type to use in the response.
104+
///
105+
/// Also called the "q-factor" or "q-value".
106+
public var quality: QualityValue
107+
108+
/// Creates a new content type from the provided parameters.
109+
/// - Parameters:
110+
/// - value: The value representing the content type.
111+
/// - quality: The quality of the content type, between 0.0 and 1.0.
112+
/// - Precondition: Quality must be in the range 0.0 and 1.0 inclusive.
113+
public init(contentType: ContentType, quality: QualityValue = 1.0) {
114+
self.quality = quality
115+
self.contentType = contentType
116+
}
117+
118+
/// Returns the default set of acceptable content types for this type, in
119+
/// the order specified in the OpenAPI document.
120+
public static var defaultValues: [Self] {
121+
ContentType.allCases.map { .init(contentType: $0) }
122+
}
123+
}
124+
125+
extension AcceptHeaderContentType: RawRepresentable {
126+
public init?(rawValue: String) {
127+
guard let validMimeType = OpenAPIMIMEType(rawValue) else {
128+
// Invalid MIME type.
129+
return nil
130+
}
131+
let quality: QualityValue
132+
if let rawQuality = validMimeType.parameters["q"] {
133+
guard let parsedQuality = QualityValue(rawValue: rawQuality) else {
134+
// Invalid quality parameter.
135+
return nil
136+
}
137+
quality = parsedQuality
138+
} else {
139+
quality = 1.0
140+
}
141+
guard let typeAndSubtype = ContentType(rawValue: validMimeType.kind.description.lowercased()) else {
142+
// Invalid type/subtype.
143+
return nil
144+
}
145+
self.init(contentType: typeAndSubtype, quality: quality)
146+
}
147+
148+
public var rawValue: String {
149+
contentType.rawValue + (quality.isDefault ? "" : "; q=\(quality.rawValue)")
150+
}
151+
}
152+
153+
extension Array {
154+
155+
/// Returns the array sorted by the quality value, highest quality first.
156+
public func sortedByQuality<T: AcceptableProtocol>() -> [AcceptHeaderContentType<T>]
157+
where Element == AcceptHeaderContentType<T> {
158+
sorted { a, b in
159+
a.quality.doubleValue > b.quality.doubleValue
160+
}
161+
}
162+
}

Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,3 @@ extension OpenAPIMIMEType: LosslessStringConvertible {
165165
.joined(separator: "; ")
166166
}
167167
}
168-
169-
extension String {
170-
fileprivate var trimmingLeadingAndTrailingSpaces: Self {
171-
trimmingCharacters(in: .whitespacesAndNewlines)
172-
}
173-
}

Sources/OpenAPIRuntime/Conversion/Converter+Client.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ import Foundation
1515

1616
extension Converter {
1717

18+
/// Sets the "accept" header according to the provided content types.
19+
/// - Parameters:
20+
/// - headerFields: The header fields where to add the "accept" header.
21+
/// - contentTypes: The array of acceptable content types by the client.
22+
public func setAcceptHeader<T: AcceptableProtocol>(
23+
in headerFields: inout [HeaderField],
24+
contentTypes: [AcceptHeaderContentType<T>]
25+
) {
26+
headerFields.append(
27+
.init(
28+
name: "accept",
29+
value: contentTypes.map(\.rawValue).joined(separator: ", ")
30+
)
31+
)
32+
}
33+
1834
// | client | set | request path | text | string-convertible | required | renderedRequestPath |
1935
public func renderedRequestPath(
2036
template: String,

Sources/OpenAPIRuntime/Conversion/Converter+Server.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ public extension Converter {
1717

1818
// MARK: Miscs
1919

20+
/// Returns the "accept" header parsed into individual content types.
21+
/// - Parameter headerFields: The header fields to inspect for an "accept"
22+
/// header.
23+
/// - Returns: The parsed content types, or the default content types if
24+
/// the header was not provided.
25+
func extractAcceptHeaderIfPresent<T: AcceptableProtocol>(
26+
in headerFields: [HeaderField]
27+
) throws -> [AcceptHeaderContentType<T>] {
28+
guard let rawValue = headerFields.firstValue(name: "accept") else {
29+
return AcceptHeaderContentType<T>.defaultValues
30+
}
31+
let rawComponents =
32+
rawValue
33+
.split(separator: ",")
34+
.map(String.init)
35+
.map(\.trimmingLeadingAndTrailingSpaces)
36+
let parsedComponents = try rawComponents.map { rawComponent in
37+
guard let value = AcceptHeaderContentType<T>(rawValue: rawComponent) else {
38+
throw RuntimeError.malformedAcceptHeader(rawComponent)
39+
}
40+
return value
41+
}
42+
return parsedComponents
43+
}
44+
2045
/// Validates that the Accept header in the provided response
2146
/// is compatible with the provided content type substring.
2247
/// - Parameters:

Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,12 @@ extension URLComponents {
101101
queryItems = groups.otherItems + [newItem]
102102
}
103103
}
104+
105+
extension String {
106+
107+
/// Returns the string with leading and trailing whitespace (such as spaces
108+
/// and newlines) removed.
109+
var trimmingLeadingAndTrailingSpaces: Self {
110+
trimmingCharacters(in: .whitespacesAndNewlines)
111+
}
112+
}

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
3737
case missingRequiredHeaderField(String)
3838
case unexpectedContentTypeHeader(String)
3939
case unexpectedAcceptHeader(String)
40+
case malformedAcceptHeader(String)
4041

4142
// Path
4243
case missingRequiredPathParameter(String)
@@ -74,6 +75,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
7475
return "Unexpected Content-Type header: \(contentType)"
7576
case .unexpectedAcceptHeader(let accept):
7677
return "Unexpected Accept header: \(accept)"
78+
case .malformedAcceptHeader(let accept):
79+
return "Malformed Accept header: \(accept)"
7780
case .missingRequiredPathParameter(let name):
7881
return "Missing required path parameter named: \(name)"
7982
case .missingRequiredQueryParameter(let name):
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import XCTest
15+
@_spi(Generated) import OpenAPIRuntime
16+
17+
enum TestAcceptable: AcceptableProtocol {
18+
case json
19+
case other(String)
20+
21+
init?(rawValue: String) {
22+
switch rawValue {
23+
case "application/json":
24+
self = .json
25+
default:
26+
self = .other(rawValue)
27+
}
28+
}
29+
30+
var rawValue: String {
31+
switch self {
32+
case .json:
33+
return "application/json"
34+
case .other(let string):
35+
return string
36+
}
37+
}
38+
39+
static var allCases: [TestAcceptable] {
40+
[.json]
41+
}
42+
}
43+
44+
final class Test_AcceptHeaderContentType: Test_Runtime {
45+
func test() throws {
46+
do {
47+
let contentType = AcceptHeaderContentType(contentType: TestAcceptable.json)
48+
XCTAssertEqual(contentType.contentType, .json)
49+
XCTAssertEqual(contentType.quality, 1.0)
50+
XCTAssertEqual(contentType.rawValue, "application/json")
51+
XCTAssertEqual(
52+
AcceptHeaderContentType<TestAcceptable>(rawValue: "application/json"),
53+
contentType
54+
)
55+
}
56+
do {
57+
let contentType = AcceptHeaderContentType(
58+
contentType: TestAcceptable.json,
59+
quality: 0.5
60+
)
61+
XCTAssertEqual(contentType.contentType, .json)
62+
XCTAssertEqual(contentType.quality, 0.5)
63+
XCTAssertEqual(contentType.rawValue, "application/json; q=0.500")
64+
XCTAssertEqual(
65+
AcceptHeaderContentType<TestAcceptable>(rawValue: "application/json; q=0.500"),
66+
contentType
67+
)
68+
}
69+
do {
70+
XCTAssertEqual(
71+
AcceptHeaderContentType<TestAcceptable>.defaultValues,
72+
[
73+
.init(contentType: .json)
74+
]
75+
)
76+
}
77+
do {
78+
let unsorted: [AcceptHeaderContentType<TestAcceptable>] = [
79+
.init(contentType: .other("*/*"), quality: 0.3),
80+
.init(contentType: .json, quality: 0.5),
81+
]
82+
XCTAssertEqual(
83+
unsorted.sortedByQuality(),
84+
[
85+
.init(contentType: .json, quality: 0.5),
86+
.init(contentType: .other("*/*"), quality: 0.3),
87+
]
88+
)
89+
}
90+
}
91+
}
92+
93+
final class Test_QualityValue: Test_Runtime {
94+
func test() {
95+
XCTAssertEqual((1 as QualityValue).doubleValue, 1.0)
96+
XCTAssertTrue((1 as QualityValue).isDefault)
97+
XCTAssertFalse(QualityValue(doubleValue: 0.5).isDefault)
98+
XCTAssertEqual(QualityValue(doubleValue: 0.5).doubleValue, 0.5)
99+
XCTAssertEqual(QualityValue(floatLiteral: 0.5).doubleValue, 0.5)
100+
XCTAssertEqual(QualityValue(integerLiteral: 0).doubleValue, 0)
101+
XCTAssertEqual(QualityValue(rawValue: "1.0")?.doubleValue, 1.0)
102+
XCTAssertEqual(QualityValue(rawValue: "0.0")?.doubleValue, 0.0)
103+
XCTAssertEqual(QualityValue(rawValue: "0.3")?.doubleValue, 0.3)
104+
XCTAssertEqual(QualityValue(rawValue: "0.54321")?.rawValue, "0.543")
105+
XCTAssertNil(QualityValue(rawValue: "hi"))
106+
}
107+
}

Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ import XCTest
1616

1717
final class Test_ClientConverterExtensions: Test_Runtime {
1818

19+
func test_setAcceptHeader() throws {
20+
var headerFields: [HeaderField] = []
21+
converter.setAcceptHeader(
22+
in: &headerFields,
23+
contentTypes: [.init(contentType: TestAcceptable.json, quality: 0.8)]
24+
)
25+
XCTAssertEqual(
26+
headerFields,
27+
[
28+
.init(name: "accept", value: "application/json; q=0.800")
29+
]
30+
)
31+
}
32+
1933
// MARK: Converter helper methods
2034

2135
// | client | set | request path | text | string-convertible | required | renderedRequestPath |

0 commit comments

Comments
 (0)