How do I make a OpenAPI ClownAPI for Hummingbird?

This article is part of a series.

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

Using the Server Section

Added to my example is a server section:

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!

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.

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.