From 9e41bcbdcac2e6821b556d7860629b00c0607f5d Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Thu, 21 Sep 2023 17:00:23 +0100 Subject: [PATCH 1/6] Add SOAR-0007 proposal: Shorthand APIs for inputs and outputs Signed-off-by: Si Beaumont --- .../Documentation.docc/Proposals/Proposals.md | 1 + .../Documentation.docc/Proposals/SOAR-0007.md | 458 ++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md index 8111e5a9..383d34fc 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md @@ -46,3 +46,4 @@ If you have any questions, tag [Honza Dvorsky](https://github.com/czechboy0) or - - - +- diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md new file mode 100644 index 00000000..a66b99a9 --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -0,0 +1,458 @@ +# SOAR-0007: Shorthand APIs for operation inputs and outputs + +Generating additional API to simplify providing operation inputs and handling +operation outputs. + +## Overview + +- Proposal: SOAR-0007 +- Author(s): [Si Beaumont](https://github.com/simonjbeaumont) +- Status: **Awaiting Review** +- Issue: + - [apple/swift-openapi-generator#22](https://github.com/apple/swift-openapi-generator/issues/22) + - [apple/swift-openapi-generator#104](https://github.com/apple/swift-openapi-generator/issues/104) + - [apple/swift-openapi-generator#145](https://github.com/apple/swift-openapi-generator/issues/145) +- Implementation: + - [simonjbeaumont/swift-openapi-generator#sb/shorthand-apis](https://github.com/apple/swift-openapi-generator/compare/main...simonjbeaumont:swift-openapi-generator:sb/shorthand-apis) + - [simonjbeaumont/swift-openapi-runtime#sb/shorthand-apis](https://github.com/apple/swift-openapi-runtime/compare/main...simonjbeaumont:swift-openapi-runtime:sb/shorthand-apis) +- Feature flag: `shorthandAPIs` +- Affected components: + - generator + - runtime +- Related links: + - [Project scope and goals](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/project-scope-and-goals) + +### Introduction + +A key goal of Swift OpenAPI Generator is to generate code that faithfully +represents the OpenAPI document[[0]] and is capable of remaining as expressive +as the OpenAPI specification, in which operations can have one of several +responses (e.g. `200`, `204`), each of which can have one of several response +bodies (e.g. `application/json`, `text/plain`). + +Consequently, the generated code allows for exhaustive type-safe handling of +all possible responses (including undocumented responses) by using nested enum +values for the HTTP status and the body. + +However, for simple operations that have just one documented outcome, the +generated API seems overly verbose to use. We discuss a concrete example in the +following section. + +### Motivation + +To motivate the proposal we will consider a trivial API which returns +a personalized greeting. The OpenAPI document for this service is provided in +an appendix, but its behaviour is illustrated with the following API call from +the terminal: + +```console +% curl 'localhost:8080/api/greet?name=Maria' +{ "message" : "Hello, Maria" } +``` + +The generated API protocols define one function per OpenAPI operation. These +functions take a single input parameter that holds all the operation inputs +(header fields, query items, cookies, body, etc.). Consequently, when making an +API call, there is an additional initializer to call. This presents unnecessary +ceremony, especially when calling operations with no parameters or only default +parameters. + +```swift +// before (with parameters) +_ = try await client.getGreeting(Operations.getGreeting.Input( + query: Operations.getGreeting.Input.Query(name: "Maria") +)) + +// before (with parameters, shorthand) +_ = try await client.getGreeting(.init(query: .init(name: "Maria"))) + +// before (no parameters, shorthand) +_ = try await client.getGreeting(.init())) +``` + +The generated `Output` type for each API operation is an enum with cases for +each documented response and a case for an undocumented response. Following +this pattern, the `Output.Body` is also an enum with cases for every documented +content type for the response. + +While this API encourages users to handle all possible scenarios, it leads to +ceremony when the user requires a specific response and receiving anything else +is considered an error. This is especially apparent for API operations that +have just a single response, e.g. `OK`, and a single content type, e.g. +`application/json`. + +```swift +// before +switch try await client.getGreeting() { +case .ok(let response): + switch response.body { + case .json(let body): + print(body.message) + } +case .undocumented(statusCode: _, _): + throw UnexpectedResponseError() +} +``` + +For users who wish to get an expected response or fail, they will have to +define their own error type. They may also make use of `guard case let ... else +{ throw ... }` which reduces the code, but still presents additional ceremony. + +### Proposed solution + +To simplify providing inputs, generate an overload for each operation that +lifts each of the parameters of `Input.init` as function parameters. This +removes the need for users to call `Input.init`, which streamlines the API +call, especially when the user does not need to provide parameters. + +```swift +// after (with parameters, shorthand) +_ = try await client.getGreeting(query: .init(name: "Maria")) + +// after (no parameters) +_ = try await client.getGreeting() +``` + +To simplify handling outputs, provide a throwing computed property for each +enum case related to a documented outcome, which will return the associated +value for the expected case, or throw a runtime error if the value is +a different enum case. This allows for expressing the expected outcome as +a chained operation. + +```swift +// after +print(try await client.getGreeting().ok.body.json.message) +// ^ ^ ^ +// | | `- (New) Throws if body did not conform to documented JSON. +// | | +// | `- (New) Throws if HTTP response is not 200 (OK). +// | +// `- (Existing) Throws if there is an error making the API call. +``` + +### Detailed design + +The following listing is a relevant subset of the code that is currently +generated for our example API. The comments have been changed for the audience +of this proposal. The entire code is contained in an appendix. + +```swift +public protocol APIProtocol: Sendable { + // A function requirement is generated for each operation. It takes an + // input type, comprising all parameters, and returns an output type, which + // is an nested enum covering all possible responses. + func getGreeting(_ input: Operations.getGreeting.Input) async throws -> Operations.getGreeting.Output +} + +public enum Operations { + public enum getGreeting { + public struct Input: Sendable, Hashable { + // If all parameters have default values, then the initializer + // parameter also has a default value. + public init( + query: Operations.getGreeting.Input.Query = .init(), + headers: Operations.getGreeting.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + case json(Components.Schemas.Greeting) + } + public var body: Operations.getGreeting.Output.Ok.Body + public init(body: Operations.getGreeting.Output.Ok.Body) { self.body = body } + } + // An enum case is generated for each documented response. + case ok(Operations.getGreeting.Output.Ok) + // An additional enum case is generated for any undocumented response. + case undocumented(statusCode: Int, OpenAPIRuntime.UndocumentedPayload) + } + } +} +``` + +This proposal covers generating the following additional API surface to +simplify providing inputs. + +```swift +extension APIProtocol { + // The parameters to each overload will match those of the corresponding + // operation input initializer, including optionality. + public func getGreeting( + query: Operations.getGreeting.Input.Query = .init(), + headers: Operations.getGreeting.Input.Headers = .init() + ) { + // Simply wraps the call to the protocol function in an input value. + getGreeting(Operations.getGreeting.Input( + query: query, + headers: headers + )) + } +} +``` + +This proposal also covers generating the following additional API surface to +simplify handling outputs. + +```swift +// Note: Generating an extension is not prescriptive; implementations may +// generate these properties within the primary type definition. +extension Operations.getGreeting.Output { + // A throwing computed property is generated for each documented outcome. + var ok: Operations.getGreeting.Output.Ok { + get throws { + guard case let .ok(response) = self else { + // This error will be added to the OpenAPIRuntime library. + throw UnexpectedResponseError(expected: "ok", actual: self) + } + return response + } + } + // Note: a property is _not_ generated for the undocumented enum case. +} + +// Note: Generating an extension is not prescriptive; implementations may +// generate these properties within the primary type definition. +extension Operations.getGreeting.Output.Ok.Body { + // A throwing computed property is generated for each document content type. + var json: Components.Schemas.Greeting { + get throws { + guard case let .json(body) = self else { + // This error will be added to the OpenAPIRuntime library. + throw UnexpectedContentError(expected: "json", actual: self) + } + return body + } + } +} +``` + +### API stability + +This change is purely API additive: + +- Additional SPI in the runtime library +- Additional API in the generated code + +### Future directions + +Nothing in this proposal. + +### Alternatives considered + +#### Providing macros + +A macro library could be used in conjunction with the existing generated code. + +However, this proposal does not consider this a viable option for two reasons: + +1. We currently support Swift 5.8; and +2. Adopters that rely on ahead-of-time generation will not benefit. + +#### Making this an opt-in feature + +There generator could conditionally generate this code, e.g. using +a configuration option, or hiding the generated code behind an SPI. + +This proposal does so unconditionally in the spirit of _making easy things, +easy._ Based on adopter feedback, enough users want to be able to do this that +it should be very discoverable on first use. + +### Appendix A: OpenAPI document for example service + +```yaml +# openapi.yaml +# ------------ +openapi: '3.0.3' +info: + title: GreetingService + version: 1.0.0 +servers: + - url: https://example.com/api + description: Example +paths: + /greet: + get: + operationId: getGreeting + parameters: + - name: name + required: false + in: query + description: A name used in the returned greeting. + schema: + type: string + responses: + '200': + description: A success response with a greeting. + content: + application/json: + schema: + $ref: '#/components/schemas/Greeting' +components: + schemas: + Greeting: + type: object + properties: + message: + type: string + required: + - message +``` + + +### Appendix B: Existing generated code for example service + +Generated using the following command: + +```console +% swift-openapi-generator generate --mode types openapi.yaml +``` + +```swift +// Types.swift +// ----------- +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +#else +import struct Foundation.URL +import struct Foundation.Data +import struct Foundation.Date +#endif +/// A type that performs HTTP operations defined by the OpenAPI document. +public protocol APIProtocol: Sendable { + /// - Remark: HTTP `GET /greet`. + /// - Remark: Generated from `#/paths//greet/get(getGreeting)`. + func getGreeting(_ input: Operations.getGreeting.Input) async throws -> Operations.getGreeting.Output +} +/// Server URLs defined in the OpenAPI document. +public enum Servers { + /// Example + public static func server1() throws -> URL { try URL(validatingOpenAPIServerURL: "https://example.com/api") } +} +/// Types generated from the components section of the OpenAPI document. +public enum Components { + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + public enum Schemas { + /// - Remark: Generated from `#/components/schemas/Greeting`. + public struct Greeting: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Greeting/message`. + public var message: Swift.String + /// Creates a new `Greeting`. + /// + /// - Parameters: + /// - message: + public init(message: Swift.String) { self.message = message } + public enum CodingKeys: String, CodingKey { case message } + } + } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + public enum Parameters {} + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + public enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + public enum Responses {} + /// Types generated from the `#/components/headers` section of the OpenAPI document. + public enum Headers {} +} +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +public enum Operations { + /// - Remark: HTTP `GET /greet`. + /// - Remark: Generated from `#/paths//greet/get(getGreeting)`. + public enum getGreeting { + public static let id: String = "getGreeting" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/greet/GET/query`. + public struct Query: Sendable, Hashable { + /// A name used in the returned greeting. + /// + /// - Remark: Generated from `#/paths/greet/GET/query/name`. + public var name: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - name: A name used in the returned greeting. + public init(name: Swift.String? = nil) { self.name = name } + } + public var query: Operations.getGreeting.Input.Query + /// - Remark: Generated from `#/paths/greet/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: + [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init( + accept: [OpenAPIRuntime.AcceptHeaderContentType] = + .defaultValues() + ) { self.accept = accept } + } + public var headers: Operations.getGreeting.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + public init( + query: Operations.getGreeting.Input.Query = .init(), + headers: Operations.getGreeting.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/greet/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/greet/GET/responses/200/content/application\/json`. + case json(Components.Schemas.Greeting) + } + /// Received HTTP response body + public var body: Operations.getGreeting.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getGreeting.Output.Ok.Body) { self.body = body } + } + /// A success response with a greeting. + /// + /// - Remark: Generated from `#/paths//greet/get(getGreeting)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getGreeting.Output.Ok) + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(String) + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "application/json": self = .json + default: self = .other(rawValue) + } + } + public var rawValue: String { + switch self { + case let .other(string): return string + case .json: return "application/json" + } + } + public static var allCases: [Self] { [.json] } + } + } +} +``` + +[0]: https://swiftpackageindex.com/apple/swift-openapi-generator/0.2.2/documentation/swift-openapi-generator/project-scope-and-goals#Principle-Faithfully-represent-the-OpenAPI-document From ab7e2630fba5f56b9e68e59deb46d56cd3557d09 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Fri, 22 Sep 2023 10:32:56 +0100 Subject: [PATCH 2/6] Update status to in-review and add review period --- .../Documentation.docc/Proposals/SOAR-0007.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md index a66b99a9..c35d3977 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -7,7 +7,9 @@ operation outputs. - Proposal: SOAR-0007 - Author(s): [Si Beaumont](https://github.com/simonjbeaumont) -- Status: **Awaiting Review** +- Status: **In Review** + - Review period: 2023-09-22 – 2023-09-27 + - [Swift Forums post](https://forums.swift.org/t/proposal-soar-0007-shorthand-apis-for-inputs-and-outputs/67444) - Issue: - [apple/swift-openapi-generator#22](https://github.com/apple/swift-openapi-generator/issues/22) - [apple/swift-openapi-generator#104](https://github.com/apple/swift-openapi-generator/issues/104) @@ -21,6 +23,8 @@ operation outputs. - runtime - Related links: - [Project scope and goals](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/project-scope-and-goals) +- Versions: + - v1 (2023-09-22): Initial version ### Introduction From 2c202ae4dcf7476c59090cc0da561902381c8ed2 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Thu, 28 Sep 2023 10:51:15 +0100 Subject: [PATCH 3/6] Fixup incorrect review period end date Signed-off-by: Si Beaumont --- .../Documentation.docc/Proposals/SOAR-0007.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md index c35d3977..52f18577 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -8,7 +8,7 @@ operation outputs. - Proposal: SOAR-0007 - Author(s): [Si Beaumont](https://github.com/simonjbeaumont) - Status: **In Review** - - Review period: 2023-09-22 – 2023-09-27 + - Review period: 2023-09-22 – 2023-09-29 - [Swift Forums post](https://forums.swift.org/t/proposal-soar-0007-shorthand-apis-for-inputs-and-outputs/67444) - Issue: - [apple/swift-openapi-generator#22](https://github.com/apple/swift-openapi-generator/issues/22) From d824bb04b13d3c908b2cdef71b53f1154e34c488 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Thu, 28 Sep 2023 10:58:38 +0100 Subject: [PATCH 4/6] Add missing issue reference --- .../Documentation.docc/Proposals/SOAR-0007.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md index 52f18577..1ac12677 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -13,6 +13,7 @@ operation outputs. - Issue: - [apple/swift-openapi-generator#22](https://github.com/apple/swift-openapi-generator/issues/22) - [apple/swift-openapi-generator#104](https://github.com/apple/swift-openapi-generator/issues/104) + - [apple/swift-openapi-generator#105](https://github.com/apple/swift-openapi-generator/issues/105) - [apple/swift-openapi-generator#145](https://github.com/apple/swift-openapi-generator/issues/145) - Implementation: - [simonjbeaumont/swift-openapi-generator#sb/shorthand-apis](https://github.com/apple/swift-openapi-generator/compare/main...simonjbeaumont:swift-openapi-generator:sb/shorthand-apis) From c53a21dae113215d64298254e5d881d1e3414890 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Tue, 3 Oct 2023 10:07:13 +0100 Subject: [PATCH 5/6] Update status, add links to PRs, remove feature flag --- .../Documentation.docc/Proposals/SOAR-0007.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md index 1ac12677..33736afe 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -7,7 +7,7 @@ operation outputs. - Proposal: SOAR-0007 - Author(s): [Si Beaumont](https://github.com/simonjbeaumont) -- Status: **In Review** +- Status: **Awaiting Implementation** - Review period: 2023-09-22 – 2023-09-29 - [Swift Forums post](https://forums.swift.org/t/proposal-soar-0007-shorthand-apis-for-inputs-and-outputs/67444) - Issue: @@ -16,9 +16,9 @@ operation outputs. - [apple/swift-openapi-generator#105](https://github.com/apple/swift-openapi-generator/issues/105) - [apple/swift-openapi-generator#145](https://github.com/apple/swift-openapi-generator/issues/145) - Implementation: - - [simonjbeaumont/swift-openapi-generator#sb/shorthand-apis](https://github.com/apple/swift-openapi-generator/compare/main...simonjbeaumont:swift-openapi-generator:sb/shorthand-apis) - - [simonjbeaumont/swift-openapi-runtime#sb/shorthand-apis](https://github.com/apple/swift-openapi-runtime/compare/main...simonjbeaumont:swift-openapi-runtime:sb/shorthand-apis) -- Feature flag: `shorthandAPIs` + - [apple/swift-openapi-runtime#56](https://github.com/apple/swift-openapi-runtime/pull/56) + - [apple/swift-openapi-generator#308](https://github.com/apple/swift-openapi-generator/pull/308) +- Feature flag: none - Affected components: - generator - runtime From baf72e4c9849b0a32f4524c4d1fb724756e52b19 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Tue, 3 Oct 2023 18:39:25 +0100 Subject: [PATCH 6/6] Update status: Implemented --- .../Documentation.docc/Proposals/SOAR-0007.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md index 33736afe..5b676488 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0007.md @@ -7,7 +7,7 @@ operation outputs. - Proposal: SOAR-0007 - Author(s): [Si Beaumont](https://github.com/simonjbeaumont) -- Status: **Awaiting Implementation** +- Status: **Implemented** - Review period: 2023-09-22 – 2023-09-29 - [Swift Forums post](https://forums.swift.org/t/proposal-soar-0007-shorthand-apis-for-inputs-and-outputs/67444) - Issue: