How do I make a OpenAPI ClownAPI for Hummingbird?
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: How do I get Hummingbird to work with the OpenAPI plugins?
- Part 8: This Article
- 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?
So now to expand out the full Clown CRUD and the updated tests. This post doesn’t review everything that can be seen in the repo example, but aspects of what it took to get there.
What Response Codes should I use?
When designing an API, follow code conventions! But WHICH code conventions. I’ll swing back around to this topic again perhaps. I’m bringing it up now mostly to say that I didn’t focus on picking the perfect codes for this API. I was focused on understanding how the OpenAPI spec and the generated code work, not writing the perfect API with it. Here are some references for future me when I do care.
Some links
- https://www.rfc-editor.org/rfc/rfc2616
- https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/
- https://apihandyman.io/empty-lists-http-status-code-200-vs-204-vs-404/
- https://apihandyman.io/hands-off-that-resource-http-status-code-401-vs-403-vs-404/
Using the Server Section
Added to my example is a server section:
- https://learn.openapis.org/specification/servers.html
- https://swagger.io/docs/specification/v3_0/api-host-and-base-path/
servers:
- url: http://localhost:{port}{basePath}
description: Local Testing
variables:
port:
enum:
- "8080"
- "2525"
default: "8080"
basePath:
default: /api/v0/clowns
I haven’t yet configured it to work with multiple servers, but it means that I can query the API for what the endpoint base route should be. In this example I chose to fail gracefully if the API endpoints didn’t register. That makes more sense when the server is handling lots of types of routes.
let repository = ClownCar()
let clown_api = ClownAPIHandler(repository: repository)
do {
print(try Servers.Server1.url())
let url = try Servers.Server1.url()
// like a addRote
try clown_api.registerHandlers(on: router, serverURL: url)
} catch {
//handle this situation.
print("hello api failed to register. Those endpoints will not be available.")
}
I added that same base route as static var to test against in my tests.
@Suite("ClownAPITests") struct ClownAPITests {
static let APIBase = "/api/v0/clowns"
//...
}
Schema
Open API can really nail down what the acceptable data should be for any type of request or response by specifying a by specifying a schema object. Here are two simple examples with two different schemas, one for a plain text response, and one that shows an extremely basic JSON response.
/hello:
get:
operationId: greet
responses:
'200':
description: A hello message as a string
content:
text/plain:
schema:
type: string
/helloworld:
get:
operationId: greetFormally
responses:
"200": # When the request is successful
description: A JSON formatted hello message
content:
application/json:
schema: # The returned JSON object
type: object
properties:
message:
type: string
required:
- message
Once again showing how to write this call the long way:
func greet(_ input: Operations.Greet.Input) async throws -> Operations.Greet.Output {
//same as:
//return .ok(.init(body: .plainText("Hello!")))
let message = "Hello"
let httpBody = OpenAPIRuntime.HTTPBody(message.utf8)
let OkBody = Operations.Greet.Output.Ok.Body.plainText(httpBody)
let greetOk = Operations.Greet.Output.Ok(body: OkBody)
let greetOutput = Operations.Greet.Output.ok(greetOk)
return greetOutput
}
func greetFormally(_ input: Operations.GreetFormally.Input) async throws -> Operations.GreetFormally.Output {
//same as:
// return .ok(.init(body:
// .json(.init(
// message: "Hello, world!"
// ))
//))
let jsonPayload = Operations.GreetFormally.Output.Ok.Body.JsonPayload(message: "Hello, world!")
let gfOkBody = Operations.GreetFormally.Output.Ok.Body.json(jsonPayload)
let gfOk = Operations.GreetFormally.Output.Ok(body: gfOkBody)
let gfOutput = Operations.GreetFormally.Output.ok(gfOk)
return greetFormallyOutput
}
Referenced Schema
Schema objects can be referenced, even in external files!
- https://json-schema.org/blog/posts/validating-openapi-and-json-schema
- https://www.speakeasy.com/openapi/schemas
- https://swagger.io/docs/specification/v3_0/data-models/data-models/
- https://json-schema.org
Since I’m likely to be using Clown’s a lot, at the bottom of the YAML I can put:
components:
schemas:
# let id: Int
# var name: String
# var spareNoses: Int
Clown:
type: object
description: A single clown profile.
properties:
id:
type: integer
name:
type: string
spareNoses:
type: integer
required:
- id
- spareNoses
- name
# other schemas...
The schema type lives inside the Types.swift
as a Components.Schemas.Clown
. I can make my life easier by adding an initializer to it that takes one of my local Clown
types.
extension Components.Schemas.Clown {
/// Maps a `Clown` to a `Components.Schemas.Clown`
/// This makes it easier to send models to the API
init(clown: Clown) {
self.init(id: clown.id, name: clown.name, spareNoses: clown.spareNoses)
}
}
Then I can make a route that depends on it:
/test_clown:
# GET /test_clown
get:
operationId: testClown
responses:
"200": # When the request is successful
description: A basic clown profile.
content:
application/json:
schema:
$ref: "#/components/schemas/Clown"
My handler that needs to return that clown has a .json
output type that expects an instance of Components.Schemas.Clown
at its initialization.
func testClown(_ input:Operations.TestClown.Input) async throws -> Operations.TestClown.Output {
let clown = Clown(id: 144214, name:"Polka Dot" , spareNoses: 56)
//see extension above.
let myClown = Components.Schemas.Clown(clown: clown)
return .ok(.init(body: .json(myClown)))
// same as:
//return .ok(.init(body: .json(.init(clown:clown))))
}
More than one response type
Endpoints can have more than one response type, one per HTTP Response code.
A kind of weak example is the current state of the delete function. Based on various conditions it will either return the clown just deleted or a .badRequest
type.
delete:
operationId: delete
parameters:
- name: id
in: path
description: Clown ID
required: true
schema:
type: integer
responses:
"200":
description: Successfully deleted the clown returned.
content:
application/json:
schema:
$ref: "#/components/schemas/Clown"
"400":
description: Was not able to delete the clown or clown already did not exist.
content:
text/plain:
schema:
type: string
func delete(_ input: Operations.Delete.Input) async throws -> Operations.Delete.Output {
let id = input.path.id
if let clown = try await repository.get(id: input.path.id) {
if try await self.repository.delete(id: id) {
return .ok(.init(body: .json(.init(clown:clown))))
} else {
//Not.
return .badRequest(.init(body: .plainText("id unable to be deleted.")))
}
} else {
//perhaps a security leak, but okay.
// this also may actually debatably be a success, in that this clown isn't in the DB any more. Consider a 204.
return .badRequest(.init(body: .plainText("id already does not exist.")))
}
}
For 4XX and 5XX responses, consider including a Problem JSON type for actual production schemas.
Undocumented Paths
The generator does give every handler an escape valve with .undocumented
outputs.
func greetFormally(_ input: Operations.GreetFormally.Input) async throws -> Operations.GreetFormally.Output {
//undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload)
let message = "Not right now thanks."
let httpBody = OpenAPIRuntime.HTTPBody(message.utf8)
return .undocumented(statusCode: 418, .init(headerFields: .init(dictionaryLiteral: []), body: httpBody))
}
Use sparingly, if at all.
Using an Input Type
Inputs have generated types as well.
post:
operationId: create
requestBody:
description: a clown to create
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ClownCreateRequest"
responses:
"200":
description: Successful creation with the clown returned.
content:
application/json:
schema:
$ref: "#/components/schemas/Clown"
"201":
description: Successful creation with the id number returned
content:
application/json:
schema: # The returned JSON object
type: object
properties:
id:
type: integer
required:
- id
"204":
description: Successful creation with empty body
"400":
description: Some bad request.
The clown create request isn’t a Clown, but what’s wanted to create a clown. Only the name
is required.
ClownCreateRequest:
type: object
description: Fields to try to create a clown from.
properties:
name:
type: string
spareNoses:
type: integer
required:
- name
func create(_ input:Operations.Create.Input) async throws -> Operations.Create.Output {
print("CREATING!!!")
//ClownCreateRequest
let (name, suggestedNoses) = switch input.body {
case .json(let clownInfo):
(clownInfo.name, clownInfo.spareNoses)
}
print(name, suggestedNoses ?? 0)
let clown = try await repository.create(name: name, spareNoses: suggestedNoses ?? 5)
return .ok(.init(body: .json(.init(clown: clown))))
}
Changes To The Tests
Most of the tests remain largely the same, but what to expect on the unhappy paths had to be updated.
My nil == 204
problem can gets “fixed” in OpenAPI because returning a Clown?
just isn’t a thing one can do.
func fetchByID(_ input:Operations.FetchByID.Input) async throws -> Operations.FetchByID.Output {
if let clown = try await repository.get(id: input.path.id) {
return .ok(.init(body: .json(.init(clown:clown))))
} else {
return .notFound(.init(body: .plainText("Was not able to retrieve that clown.")))
}
}
My one real beef with OpenAPI is how it handles Decoding errors.
Decoding errors get thrown as 500’s before they even hit the handler. I would argue that its more likely that the client sent a badly formatted request than there being an error in the decoder code. A 500 encourages the client to send the exact same request later with no changes.
Hopefully sometime later 2025 / early 2026 I’ll be able to be more helpful than just complainy pants about that.
- https://github.com/swift-server/swift-openapi-hummingbird/issues/26#issuecomment-3250491543
- https://forums.swift.org/t/openapi-trying-to-use-soar-0011-errorhandlingmiddleware/
In the mean time, the tests look for that expectation:
let notAnID = "jieoGEJg" //change to malformed
let _ = try await client.execute(uri: "\(Self.APIBase)/\(notAnID)", method: .get) {
response in
// TODO: THIS IS BAD
// EXAMPLE OF 500 PROBLEM!!!
// #expect(response.status == .badRequest)
#expect(response.status == .internalServerError)
}
Summary
So I can see where codifying the API would be invaluable for production servers and for APIs that will have a lot of clients. Having generated code that doesn’t allow for mistakes, fantastic. The ability to share this API in a language agnostic way, also incredibly useful.
On the negative side, I felt like the really long type names from the generator encourages writing hard to read code. I also missed having the ability to intercept more of errors from within the handler.
There’s more to say about the generator and Hummingbird but the next few posts will be back to hardware!
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: How do I get Hummingbird to work with the OpenAPI plugins?
- Part 8: This Article
- 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?