SOAR-0007: Shorthand APIs for operation inputs and outputs

Generating additional API to simplify providing operation inputs and handling operation outputs.

SOAR-0007.md

Overview

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:

% 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.

// 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.

// 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.

// 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.

// 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.

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.

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.

// 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

# 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:

% swift-openapi-generator generate --mode types openapi.yaml
// 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<Operations.getGreeting.AcceptableContentType>]
                /// Creates a new `Headers`.
                ///
                /// - Parameters:
                ///   - accept:
                public init(
                    accept: [OpenAPIRuntime.AcceptHeaderContentType<Operations.getGreeting.AcceptableContentType>] =
                        .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] }
        }
    }
}