Skip to content

Commit 640a58c

Browse files
authored
[Proposal] SOAR-0003 Type-safe Accept headers (#201)
### Motivation See proposal. ### Modifications N/A ### Result N/A ### Test Plan N/A
1 parent 0bebccd commit 640a58c

File tree

2 files changed

+266
-0
lines changed

2 files changed

+266
-0
lines changed

Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ If you have any questions, tag [Honza Dvorsky](https://github.com/czechboy0) or
4545
- <doc:SOAR-NNNN>
4646
- <doc:SOAR-0001>
4747
- <doc:SOAR-0002>
48+
- <doc:SOAR-0003>
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# SOAR-0003: Type-safe Accept headers
2+
3+
Generate a dedicated Accept header enum for each operation.
4+
5+
## Overview
6+
7+
- Proposal: SOAR-0003
8+
- Author(s): [Honza Dvorsky](https://github.com/czechboy0), [Si Beaumont](https://github.com/simonjbeaumont)
9+
- Status: **In Preview**
10+
- Issue: [apple/swift-openapi-generator#160](https://github.com/apple/swift-openapi-generator/issues/160)
11+
- Implementation:
12+
- [apple/swift-openapi-runtime#37](https://github.com/apple/swift-openapi-runtime/pull/37)
13+
- [apple/swift-openapi-generator#185](https://github.com/apple/swift-openapi-generator/pull/185)
14+
- Feature flag: `multipleContentTypes`
15+
- Affected components:
16+
- generator
17+
- runtime
18+
19+
### Introduction
20+
21+
Generate a type-safe representation of the possible values in the Accept header for each operation.
22+
23+
### Motivation
24+
25+
#### Accept header
26+
27+
The [Accept request header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) allows the client to communicate to the server which content types the client can handle in the response body. This includes the ability to provide multiple values, and to give each a numeric value to that represents preference (called "quality").
28+
29+
Many clients don't provide any preference, for example by not including the Accept header, providing `accept: */*`, or listing all the known response headers in a list. The last option is what our generated clients do by default already today.
30+
31+
However, sometimes the client needs to narrow down the list of acceptable content types, or it prefers one over the other, while it can still technically handle both.
32+
33+
For example, let's consider an operation that returns an image either in the `png` or `jpeg` format. A client with a low amount of CPU and memory might choose `jpeg`, even though it could also handle `png`. In such a scenario, it would send an Accept header that could look like: `accept: image/jpeg, image/png; q=0.1`. This tells the server that while the client can handle both formats, it really prefers `jpeg`. Note that the "q" parameter represents a priority value between `0.0` and `1.0` inclusive, and the default value is `1.0`.
34+
35+
However, the client could also completely lack a `png` decoder, in which case it would only request the `jpeg` format with: `accept: image/jpeg`. Note that `image/png` is completely omitted from the Accept header in this case.
36+
37+
To summarize, the client needs to _provide_ Accept header information, and the server _inspects_ that information and uses it as a hint. Note that the server is still in charge of making the final decision over which of the acceptable content types it chooses, or it can return a 4xx status code if it cannot satisfy the client's request.
38+
39+
#### Existing behavior
40+
41+
Today, the generated client includes in the Accept header all the content types that appear in any response for the invoked operation in the OpenAPI document, essentially allowing the server to pick any content type. For an operation that uses JSON and plain text, the header would be: `accept: application/json, text/plain`. However, there is no way for the client to narrow down the choices or customize the quality value, meaning the only workaround is to build a [`ClientMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/0.1.8/documentation/openapiruntime/clientmiddleware) that modifies the raw HTTP request before it's executed by the transport.
42+
43+
On the server side, adopters have had to resort to workarounds, such as extracting the Accept header in a custom [`ServerMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/0.1.8/documentation/openapiruntime/servermiddleware) and saving the parsed value into a task local value.
44+
45+
#### Why now?
46+
47+
While the Accept header can be sent even with requests that only have one documented response content type, it is most useful when the response contains multiple possible content types.
48+
49+
That's why we are proposing this feature now, since multiple content types recently got implemented in Swift OpenAPI Generator - hidden behind the feature flag `multipleContentTypes` in versions `0.1.7+`.
50+
51+
### Proposed solution
52+
53+
We propose to start generating a new enum in each operation's namespace that contains all the unique concrete content types that appear in any of the operation's responses. This enum would also have a case called `other` with an associated `String` value, which would be an escape hatch, similar to the `undocumented` case generated today for undocumented response codes.
54+
55+
This enum would be used by a new property that would be generated on every operation's `Input.Headers` struct, allowing clients a type-safe way to set, and servers to get, this information, represented as an array of enum values each wrapped in a type that also includes the quality value.
56+
57+
### Example
58+
59+
For example, let's consider the following operation:
60+
61+
```yaml
62+
/stats:
63+
get:
64+
operationId: getStats
65+
responses:
66+
'200':
67+
description: A successful response with stats.
68+
content:
69+
application/json:
70+
schema:
71+
...
72+
text/plain: {}
73+
```
74+
75+
The generated code in `Types.swift` would gain an enum definition and a property on the headers struct.
76+
77+
> Note: The code snippet below is simplified, access modifiers and most protocol conformances are omitted, and so on. For a full example, check out the changes to the integration tests in the [generator PR](https://github.com/apple/swift-openapi-generator/pull/185).
78+
79+
```diff
80+
// Types.swift
81+
// ...
82+
enum Operations {
83+
enum getStats {
84+
struct Input {
85+
struct Headers {
86+
+ var accept: [AcceptHeaderContentType<
87+
+ Operations.getStats.AcceptableContentType
88+
+ >] = .defaultValues()
89+
}
90+
}
91+
enum Output {
92+
// ...
93+
}
94+
+ enum AcceptableContentType: AcceptableProtocol {
95+
+ case json
96+
+ case plainText
97+
+ case other(String)
98+
+ }
99+
}
100+
}
101+
```
102+
103+
As a client adopter, you would be able to set the new defaulted property `accept` on `Input.Headers`. The following invocation of the `getStats` operation tells the server that the JSON content type is preferred over plain text, but both are acceptable.
104+
105+
```swift
106+
let response = try await client.getStats(.init(
107+
headers: .init(accept: [
108+
.init(contentType: .json),
109+
.init(contentType: .plainText, quality: 0.5)
110+
])
111+
))
112+
```
113+
114+
You could also leave it to its default value, which sends the full list of content types documented in the responses for this operation - which is the existing behavior.
115+
116+
As a server implementer, you would inspect the provided Accept information for example by sorting it by quality (highest first), and always returning the most preferred content type. And if no Accept header is provided, this implementation defaults to JSON.
117+
118+
```swift
119+
struct MyHandler: APIProtocol {
120+
func getStats(_ input: Operations.getStats.Input) async throws -> Operations.getStats.Output {
121+
let contentType = input
122+
.headers
123+
.accept
124+
.sortedByQuality()
125+
.first?
126+
.contentType ?? .json
127+
switch contentType {
128+
case .json:
129+
// ... return JSON
130+
case .plainText:
131+
// ... return plain text
132+
case .other(let value):
133+
// ... inspect the value or return an error
134+
}
135+
}
136+
}
137+
```
138+
139+
### Detailed design
140+
141+
This feature requires a new API in the runtime library, in addition to the new generated code.
142+
143+
#### New runtime library APIs
144+
145+
```swift
146+
/// The protocol that all generated `AcceptableContentType` enums conform to.
147+
public protocol AcceptableProtocol : CaseIterable, Hashable, RawRepresentable, Sendable where Self.RawValue == String {}
148+
149+
/// A wrapper of an individual content type in the accept header.
150+
public struct AcceptHeaderContentType<ContentType> : Sendable, Equatable, Hashable where ContentType : Acceptable.AcceptableProtocol {
151+
152+
/// The value representing the content type.
153+
public var contentType: ContentType
154+
155+
/// The quality value of this content type.
156+
///
157+
/// Used to describe the order of priority in a comma-separated
158+
/// list of values.
159+
///
160+
/// Content types with a higher priority should be preferred by the server
161+
/// when deciding which content type to use in the response.
162+
///
163+
/// Also called the "q-factor" or "q-value".
164+
public var quality: QualityValue
165+
166+
/// Creates a new content type from the provided parameters.
167+
/// - Parameters:
168+
/// - value: The value representing the content type.
169+
/// - quality: The quality of the content type, between 0.0 and 1.0.
170+
/// - Precondition: Priority must be in the range 0.0 and 1.0 inclusive.
171+
public init(contentType: ContentType, quality: QualityValue = 1.0)
172+
173+
/// Returns the default set of acceptable content types for this type, in
174+
/// the order specified in the OpenAPI document.
175+
public static var defaultValues: [`Self`] { get }
176+
}
177+
178+
/// A quality value used to describe the order of priority in a comma-separated
179+
/// list of values, such as in the Accept header.
180+
public struct QualityValue : Sendable, Equatable, Hashable {
181+
182+
/// Creates a new quality value of the default value 1.0.
183+
public init()
184+
185+
/// Returns a Boolean value indicating whether the quality value is
186+
/// at its default value 1.0.
187+
public var isDefault: Bool { get }
188+
189+
/// Creates a new quality value from the provided floating-point number.
190+
///
191+
/// - Precondition: The value must be between 0.0 and 1.0, inclusive.
192+
public init(doubleValue: Double)
193+
194+
/// The value represented as a floating-point number between 0.0 and 1.0, inclusive.
195+
public var doubleValue: Double { get }
196+
}
197+
198+
extension QualityValue : RawRepresentable { ... }
199+
extension QualityValue : ExpressibleByIntegerLiteral { ... }
200+
extension QualityValue : ExpressibleByFloatLiteral { ... }
201+
extension AcceptHeaderContentType : RawRepresentable { ... }
202+
203+
extension Array {
204+
/// Returns the array sorted by the quality value, highest quality first.
205+
public func sortedByQuality<T>() -> [AcceptHeaderContentType<T>] where Element == Acceptable.AcceptHeaderContentType<T>, T : Acceptable.AcceptableProtocol
206+
207+
/// Returns the default values for the acceptable type.
208+
public static func defaultValues<T>() -> [AcceptHeaderContentType<T>] where Element == Acceptable.AcceptHeaderContentType<T>, T : Acceptable.AcceptableProtocol
209+
}
210+
```
211+
212+
The generated operation-specific enum called `AcceptableContentType` conforms to the `AcceptableProtocol` protocol.
213+
214+
A full example of a generated `AcceptableContentType` for `getStats` looks like this:
215+
216+
```swift
217+
@frozen public enum AcceptableContentType: AcceptableProtocol {
218+
case json
219+
case plainText
220+
case other(String)
221+
public init?(rawValue: String) {
222+
switch rawValue.lowercased() {
223+
case "application/json": self = .json
224+
case "text/plain": self = .plainText
225+
default: self = .other(rawValue)
226+
}
227+
}
228+
public var rawValue: String {
229+
switch self {
230+
case let .other(string): return string
231+
case .json: return "application/json"
232+
case .plainText: return "text/plain"
233+
}
234+
}
235+
public static var allCases: [Self] { [.json, .plainText] }
236+
}
237+
```
238+
239+
### API stability
240+
241+
This feature is purely additive, and introduces a new property to `Input.Headers` generated structs for all operations with at least 1 documented response content type.
242+
243+
The default behavior is still the same – all documented response content types are sent in the Accept header.
244+
245+
### Future directions
246+
247+
#### Support for wildcards
248+
249+
One deliberate omission from this design is the support for wildcards, such as `*/*` or `application/*`. If such a value needs to be sent or received, the adopter is expected to use the `other(String)` case.
250+
251+
While we discussed this topic at length, we did not arrive at a solution that would provide enough added value for the extra complexity, so it is left up to future proposals to solve, or for real-world usage to show that nothing more is necessary.
252+
253+
### Alternatives considered
254+
255+
#### A stringly array
256+
257+
The `accept` property could have simply been `var accept: [String]`, where the generated code would only concatenate or split the header value with a comma, but then leave it to the adopter to construct or parse the type, subtype, and optional quality parameter.
258+
259+
That seemed to go counter to this project's goals of making access to the information in the OpenAPI document as type-safe as possible, helping catch bugs at compile time.
260+
261+
#### Maintaing the status quo
262+
263+
We also could have not implemented anything, leaving adopters who need to customize the Accept header to inject or extract that information with a middleware, both on the client and server side.
264+
265+
That option was rejected as without explicit support for setting and getting the Accept header information, the support for multiple content types seemed incomplete.

0 commit comments

Comments
 (0)