How do I get Hummingbird to work with the OpenAPI plugins?
This article is part of a series.
- Part 1: How can I have Hummingbird serve static files?
- Part 2: What kinda routes can I use with Hummingbird?
- Part 3: How do I test the Hummingbird routes with swift-testing?
- Part 4: How do I get Hummingbird to serve html dynamically?
- Part 5: How do I get Hummingbird to use Mustache templates instead?
- Part 6: How do I use Hummingbird to make a basic CRUD API?
- Part 7: This Article
- Part 8: How do I make a OpenAPI ClownAPI for Hummingbird?
- Part 9: So how exactly does Middleware come into Hummingbird?
- Part 10: How about catching errors with Hummingbird Middleware?
- Part 11: So what even is a Hummingbird RequestContext?
- Part 12: Why would I want a Hummingbird ChildRequestContext?
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:
- https://learn.openapis.org
- https://swagger.io/docs/specification/v3_0/about/
- https://support.smartbear.com/swaggerhub/docs/en/get-started/openapi-3-0-tutorial.html
- Meet Swift OpenAPI Generator: https://developer.apple.com/videos/play/wwdc2023/10171
- https://www.swift.org/blog/introducing-swift-openapi-generator/
- https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/tutorials/swift-openapi-generator
- https://swiftonserver.com/using-openapi-with-hummingbird/
- https://github.com/apple/swift-openapi-generator/tree/main/Examples/hello-world-hummingbird-server-example
- https://forums.swift.org/tag/openapi
- https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-mongokitten-openapi
- https://github.com/swift-server/swift-openapi-hummingbird
- non-swift alternative: https://fastapi.tiangolo.com
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
- ClownAPI.swift: empty file to trigger the package to compile
- openapi-generator-config.yaml: the file that tells the generator what types of files to generate
- openapi.yaml: the file that says what rules the API should follow
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
- The
OpenAPIHummingbird
glue library needs to be imported - Optionally the
OpenAPIRequestContextMiddleware()
generated by the template has to be added to the Middleware Group. It’s needed if a Hummingbird context will be used in an OpenAPIRuntime endpoint handler. It does this by storing the Context in TaskLocal (more, reading) - The Routes need to be added! The new packages (generator, runtime, hummingbird-openapi) do some fancy magic under the hood with conjuring up the proper
registerHandlers
function for the desired server underpinning. Yet another possible post / set of posts…
//# 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.
- Part 1: How can I have Hummingbird serve static files?
- Part 2: What kinda routes can I use with Hummingbird?
- Part 3: How do I test the Hummingbird routes with swift-testing?
- Part 4: How do I get Hummingbird to serve html dynamically?
- Part 5: How do I get Hummingbird to use Mustache templates instead?
- Part 6: How do I use Hummingbird to make a basic CRUD API?
- Part 7: This Article
- Part 8: How do I make a OpenAPI ClownAPI for Hummingbird?
- Part 9: So how exactly does Middleware come into Hummingbird?
- Part 10: How about catching errors with Hummingbird Middleware?
- Part 11: So what even is a Hummingbird RequestContext?
- Part 12: Why would I want a Hummingbird ChildRequestContext?