How do I get Hummingbird to work with the OpenAPI plugins?

This article is part of a series.

What is OpenAPI? Originally part of swagger in the their own words from the docs:

The OpenAPI Specification (OAS) provides a consistent means to carry information through each stage of the API lifecycle. It is a specification language for HTTP APIs that defines structure and syntax in a way that is not wedded to the programming language the API is created in. API specifications are typically written in YAML or JSON, allowing for easy sharing and consumption of the specification.

That YAML file might look something like

openapi: '3.1.0'
info:
  title: AppService
  version: 1.0.0
servers:
  - url: https://example.com/api
    description: App service deployment.
paths:
  /:
    get:
      operationId: getHello
      responses:
        '200':
          description: Hello!
          content:
            text/plain:
              schema:
                type: string

That document describes a service that offers one API endpoint “/”. It is expected to return a HTTP response with a status code of “200” and the content type of “text/plain” with the data schema of an unnamed string.

That YAML comes from the starter template for Hummingbird. Running the download script and following the prompts creates a package with all the necessary dependencies for use with Hummingbird already installed.

curl -L https://raw.githubusercontent.com/hummingbird-project/template/main/scripts/download.sh | bash

Some things to notice include that the YAML file I just showed lives in its own library. The premise being that that will change infrequently and as a dependency will be compiled first. This is important because the swift-openapi-generator creates code it puts straight in the build folder.

For example:

ls -al .build/plugins/outputs/helloopenapi/AppAPI/destination/OpenAPIGenerator/GeneratedSources
##yields: Client.swift	Server.swift	Types.swift

That glue code will make it possible to make compiler checked type constrained endpoint handlers.

Resources

Before going any further, it will be easier to follow along after at least skimming some of the resources below:

What’s in The Basic Example

After creating the basic example I changed the name of my server app to “circusServer” and the API to the “ClownAPI”. I change names like that to try to pull apart what’s a defined term and what’s a convention in the generated code.

Package.swift

I moved the tools up to 6.2 and everything still works.

// swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "circusServer",
    platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
    products: [
        .executable(name: "circusServer", targets: ["circusServer"]),
    ],
    dependencies: [
        .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"),
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
        .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.6.0"),
        .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.7.0"),
        .package(url: "https://github.com/swift-server/swift-openapi-hummingbird.git", from: "2.0.1"),
    ],
    targets: [
        .executableTarget(name: "circusServer",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "Hummingbird", package: "hummingbird"),
                .byName(name: "ClownAPI"),
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIHummingbird", package: "swift-openapi-hummingbird"),
            ],
            path: "Sources/Circus"
        ),
        .target(
            name: "ClownAPI",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime")
            ],
            path: "Sources/ClownAPI",
            plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")],
        ),
        .testTarget(name: "AppTests",
            dependencies: [
                .byName(name: "circusServer"),
                .product(name: "HummingbirdTesting", package: "hummingbird")
            ],
            path: "Tests/AppTests"
        )
    ]
)

ClownAPI

The clown API folder has 3 files

I showed the contents of openapi.yaml above, but the generator config contains

generate:
  - types
  - server
accessModifier: package
namingStrategy: idiomatic

After running a build, cat‘ing out the contents of the Client.swift will reveal it to be an empty file because the config does not request a client.

## new folder after the name changes
ls -al .build/plugins/outputs/helloopenapi/ClownAPI/destination/OpenAPIGenerator/GeneratedSources
##yields: Client.swift	Server.swift	Types.swift
cat .build/plugins/outputs/helloopenapi/ClownAPI/destination/OpenAPIGenerator/GeneratedSources/Client.swift

Defining The Handlers

Back in the server, instead of a ClownController, this time there’s an APIImplementation.swift, which implements the APIProtocol placed by the generator in Types.swift. (The name APIImplementation can be changed and will be ClownAPIHandler in the finished demo.)

import ClownAPI
import OpenAPIRuntime

struct APIImplementation: APIProtocol {
    func getHello(_ input: ClownAPI.Operations.GetHello.Input) async throws -> ClownAPI.Operations.GetHello.Output {
        return .ok(.init(body: .plainText("Hello!")))
    }
}

Excerpt from Types.swift

/// A type that performs HTTP operations defined by the OpenAPI document.
package protocol APIProtocol: Sendable {
    /// - Remark: HTTP `GET /`.
    /// - Remark: Generated from `#/paths///get(getHello)`.
    func getHello(_ input: Operations.GetHello.Input) async throws -> Operations.GetHello.Output
}

But what’s the deal with those crazy input and output types? They get generated too, for each route there will be a Operations.$ROUTE_NAME that will hold the expected input types and allowed output types outlined by the openapi.yaml.

package static let id: Swift.String = "getHello"
        package struct Input: Sendable, Hashable {
            /// - Remark: Generated from `#/paths/GET/header`.
            package struct Headers: Sendable, Hashable {
                package var accept: [OpenAPIRuntime.AcceptHeaderContentType<Operations.GetHello.AcceptableContentType>]
                /// Creates a new `Headers`.
                ///
                /// - Parameters:
                ///   - accept:
                package init(accept: [OpenAPIRuntime.AcceptHeaderContentType<Operations.GetHello.AcceptableContentType>] = .defaultValues()) {
                    self.accept = accept
                }
            }
            package var headers: Operations.GetHello.Input.Headers
            /// Creates a new `Input`.
            ///
            /// - Parameters:
            ///   - headers:
            package init(headers: Operations.GetHello.Input.Headers = .init()) {
                self.headers = headers
            }
        }
        @frozen package enum Output: Sendable, Hashable {
            package struct Ok: Sendable, Hashable {
                /// - Remark: Generated from `#/paths/GET/responses/200/content`.
                @frozen package enum Body: Sendable, Hashable {
                    /// - Remark: Generated from `#/paths/GET/responses/200/content/text\/plain`.
                    case plainText(OpenAPIRuntime.HTTPBody)
                    /// The associated value of the enum case if `self` is `.plainText`.
                    ///
                    /// - Throws: An error if `self` is not `.plainText`.
                    /// - SeeAlso: `.plainText`.
                    package var plainText: OpenAPIRuntime.HTTPBody {
                        get throws {
                            switch self {
                            case let .plainText(body):
                                return body
                            }
                        }
                    }
                }
                /// Received HTTP response body
                package var body: Operations.GetHello.Output.Ok.Body
                /// Creates a new `Ok`.
                ///
                /// - Parameters:
                ///   - body: Received HTTP response body
                package init(body: Operations.GetHello.Output.Ok.Body) {
                    self.body = body
                }
            }
            /// Hello!
            ///
            /// - Remark: Generated from `#/paths///get(getHello)/responses/200`.
            ///
            /// HTTP response code: `200 ok`.
            case ok(Operations.GetHello.Output.Ok)
            /// The associated value of the enum case if `self` is `.ok`.
            ///
            /// - Throws: An error if `self` is not `.ok`.
            /// - SeeAlso: `.ok`.
            package var ok: Operations.GetHello.Output.Ok {
                get throws {
                    switch self {
                    case let .ok(response):
                        return response
                    default:
                        try throwUnexpectedResponseStatus(
                            expectedStatus: "ok",
                            response: self
                        )
                    }
                }
            }
            /// Undocumented response.
            ///
            /// A response with a code that is not documented in the OpenAPI document.
            case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload)
        }

These types are enums within enums, so looking at the the getHello example above, we have the Output enum with the case ok taking in a Body enum with its single allowable case of .plainText that has an associated type of OpenAPIRuntime.HTTPBody. OpenAPIRuntime.HTTPBody has several convenience initializers, but none of them actually take a String. What we’re seeing in the example is a StaticString, which behaves differently. A string can be used, it just needs to be transformed into its UInt8 bytes first.

// Works
func getHello(_ input: ClownAPI.Operations.GetHello.Input) async throws -> ClownAPI.Operations.GetHello.Output {
    return .ok(.init(body: .plainText("Hello!")))
}

// Does not work. 
func getHello(_ input: Operations.GetHello.Input) async throws -> Operations.GetHello.Output {
    // 1.
    let message = "Hello!"
    //Cannot convert value of type 'String' to expected argument type 'HTTPBody'
    return .ok(.init(body: .plainText(message)))
}

// Does work
func getHello(_ input: Operations.GetHello.Input) async throws -> Operations.GetHello.Output {
    let message = "Hello"
    return .ok(.init(body: .plainText(.init(message.utf8))))
}

That can be hard to follow with all those .inits. Here is that last call written out the super long way:

func getHello(_ input: Operations.GetHello.Input) async throws -> Operations.GetHello.Output {
    //return .ok(.init(body: .plainText("Hello!")))
    let message = "Hello"
    let bytes:[UInt8] = Array(message.utf8) 
    let httpBody = OpenAPIRuntime.HTTPBody(bytes)
    let okBody = Operations.GetHello.Output.Ok.Body.plainText(httpBody)
    let greetOk = Operations.GetHello.Output.Ok(body: okBody)
    let greetOutput = Operations.GetHello.Output.ok(greetOk)
    return greetOutput
}

Adding the Routes

To get those functions registered with the Hummingbird router, 3 things have to happen in Application+build.swift

//# 1 Add the Glue
import OpenAPIHummingbird

/// Build router
func buildRouter() throws -> Router<AppRequestContext> {
    let router = Router(context: AppRequestContext.self)
    // Add middleware
    router.addMiddleware {
        // logging middleware
        LogRequestsMiddleware(.info)
        //# 2: store request context in TaskLocal
        OpenAPIRequestContextMiddleware()
    }
    // # 3 Add OpenAPI handlers
    let api = APIImplementation()
    try api.registerHandlers(on: router)
    return router
}

Summary

That’s all the pieces in the starter OpenAPI Hummingbird template combo before starting to add in all the CRUD implementation details.

This article is part of a series.