From 65876bf407304a65d5eda14dd96511ef12f9eb41 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 16 Aug 2023 12:31:33 +0200 Subject: [PATCH 1/5] [Proposal] SOAR-0003 Type-safe Accept headers --- .../Documentation.docc/Proposals/SOAR-0003.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md new file mode 100644 index 00000000..997cc959 --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md @@ -0,0 +1,265 @@ +# SOAR-0003: Type-safe Accept headers + +Generate a dedicated Accept header enum for each operation. + +## Overview + +- Proposal: SOAR-0003 +- Author(s): [Honza Dvorsky](https://github.com/czechboy0), [Si Beaumont](https://github.com/simonjbeaumont) +- Status: **Awaiting Review** +- Issue: [apple/swift-openapi-generator#160](https://github.com/apple/swift-openapi-generator/issues/160) +- Implementation: + - [apple/swift-openapi-runtime#37](https://github.com/apple/swift-openapi-runtime/pull/37) + - [apple/swift-openapi-generator#185](https://github.com/apple/swift-openapi-generator/pull/185) +- Feature flag: none, purely additive +- Affected components: + - generator + - runtime + +### Introduction + +Generate a type-safe representation of the possible values in the Accept header for each operation. + +### Motivation + +#### Accept header + +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"). + +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. + +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. + +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`. + +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. + +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. + +#### Existing behavior + +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. + +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. + +#### Why now? + +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. + +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+`. + +### Proposed solution + +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(String)` case generated today for enums and `oneOf`s. + +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. + +### Example + +For example, let's consider the following operation: + +```yaml +/stats: + get: + operationId: getStats + responses: + '200': + description: A successful response with stats. + content: + application/json: + schema: + ... + text/plain: {} +``` + +The generated code in `Types.swift` would gain an enum definition and a property on the headers struct. + +> 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). + +```diff +// Types.swift +// ... +enum Operations { + enum getStats { + struct Input { + struct Headers { ++ var accept: [AcceptHeaderContentType< ++ Operations.getStats.AcceptableContentType ++ >] = .defaultValues() + } + } + enum Output { + // ... + } ++ enum AcceptableContentType: AcceptableProtocol { ++ case json ++ case plainText ++ case other(String) ++ } + } +} +``` + +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. + +```swift +let response = try await client.getStats(.init( + headers: .init(accept: [ + .init(contentType: .json), + .init(contentType: .plainText, quality: 0.5) + ]) +)) +``` + +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. + +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. + +```swift +struct MyHandler: APIProtocol { + func getStats(_ input: Operations.getStats.Input) async throws -> Operations.getStats.Output { + let contentType = input + .headers + .accept + .sortedByQuality() + .first? + .contentType ?? .json + switch contentType { + case .json: + // ... return JSON + case .plainText: + // ... return plain text + case .other(let value): + // ... inspect the value or return an error + } + } +} +``` + +### Detailed design + +This feature requires a new API in the runtime library, in addition to the new generated code. + +#### New runtime library APIs + +```swift +/// The protocol that all generated `AcceptableContentType` enums conform to. +public protocol AcceptableProtocol : CaseIterable, Hashable, RawRepresentable, Sendable where Self.RawValue == String {} + +/// A wrapper of an individual content type in the accept header. +public struct AcceptHeaderContentType : Sendable, Equatable, Hashable where ContentType : Acceptable.AcceptableProtocol { + + /// 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: Priority must be in the range 0.0 and 1.0 inclusive. + public init(contentType: ContentType, quality: QualityValue = 1.0) + + /// Returns the default set of acceptable content types for this type, in + /// the order specified in the OpenAPI document. + public static var defaultValues: [`Self`] { get } +} + +/// 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, Equatable, Hashable { + + /// Creates a new quality value of the default value 1.0. + public init() + + /// Returns a Boolean value indicating whether the quality value is + /// at its default value 1.0. + public var isDefault: Bool { get } + + /// 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) + + /// The value represented as a floating-point number between 0.0 and 1.0, inclusive. + public var doubleValue: Double { get } +} + +extension QualityValue : RawRepresentable { ... } +extension QualityValue : ExpressibleByIntegerLiteral { ... } +extension QualityValue : ExpressibleByFloatLiteral { ... } +extension AcceptHeaderContentType : RawRepresentable { ... } + +extension Array { + /// Returns the array sorted by the quality value, highest quality first. + public func sortedByQuality() -> [AcceptHeaderContentType] where Element == Acceptable.AcceptHeaderContentType, T : Acceptable.AcceptableProtocol + + /// Returns the default values for the acceptable type. + public static func defaultValues() -> [AcceptHeaderContentType] where Element == Acceptable.AcceptHeaderContentType, T : Acceptable.AcceptableProtocol +} +``` + +The generated operation-specific enum called `AcceptableContentType` conforms to the `AcceptableProtocol` protocol. + +A full example of a generated `AcceptableContentType` for `getStats` looks like this: + +```swift +@frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case plainText + case other(String) + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "application/json": self = .json + case "text/plain": self = .plainText + default: self = .other(rawValue) + } + } + public var rawValue: String { + switch self { + case let .other(string): return string + case .json: return "application/json" + case .plainText: return "text/plain" + } + } + public static var allCases: [Self] { [.json, .plainText] } +} +``` + +### API stability + +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. + +The default behavior is still the same – all documented response content types are sent in the Accept header. + +### Future directions + +#### Support for wildcards + +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. + +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. + +### Alternatives considered + +#### A stringly array + +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. + +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. + +#### Maintaing the status quo + +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. + +That option was rejected as without explicit support for setting and getting the Accept header information, the support for multiple content types seemed incomplete. From a9cc1a3eaa23b0106ff78cb9fa0392c58ee44d39 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 18 Aug 2023 12:04:04 +0200 Subject: [PATCH 2/5] PR feedback: update language around the undocumented case. --- .../Documentation.docc/Proposals/SOAR-0003.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md index 997cc959..5a57fd81 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md @@ -50,7 +50,7 @@ That's why we are proposing this feature now, since multiple content types recen ### Proposed solution -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(String)` case generated today for enums and `oneOf`s. +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. 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. From 55deb3a0be9abbaeee48379d1cd01f3ade4d15ce Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 23 Aug 2023 13:58:01 +0200 Subject: [PATCH 3/5] Update status - ready for when impl PRs are landed. --- .../Documentation.docc/Proposals/SOAR-0003.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md index 5a57fd81..3ca75cee 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md @@ -6,7 +6,7 @@ Generate a dedicated Accept header enum for each operation. - Proposal: SOAR-0003 - Author(s): [Honza Dvorsky](https://github.com/czechboy0), [Si Beaumont](https://github.com/simonjbeaumont) -- Status: **Awaiting Review** +- Status: **In Preview** - Issue: [apple/swift-openapi-generator#160](https://github.com/apple/swift-openapi-generator/issues/160) - Implementation: - [apple/swift-openapi-runtime#37](https://github.com/apple/swift-openapi-runtime/pull/37) From aaf4eeb2dc131f1c4547a845d7f4f81a04876f3a Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 23 Aug 2023 13:58:34 +0200 Subject: [PATCH 4/5] Update feature flag name. --- .../Documentation.docc/Proposals/SOAR-0003.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md index 3ca75cee..dad9c4d8 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0003.md @@ -11,7 +11,7 @@ Generate a dedicated Accept header enum for each operation. - Implementation: - [apple/swift-openapi-runtime#37](https://github.com/apple/swift-openapi-runtime/pull/37) - [apple/swift-openapi-generator#185](https://github.com/apple/swift-openapi-generator/pull/185) -- Feature flag: none, purely additive +- Feature flag: `multipleContentTypes` - Affected components: - generator - runtime From 972ac9ad6e6007cb8b99c694b883fe90134b31a8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 23 Aug 2023 13:59:24 +0200 Subject: [PATCH 5/5] Link to the proposal from the list --- .../Documentation.docc/Proposals/Proposals.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md index 6669071c..8111e5a9 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md @@ -45,3 +45,4 @@ If you have any questions, tag [Honza Dvorsky](https://github.com/czechboy0) or - - - +-