How do I use Hummingbird to make a basic CRUD API?

This article is part of a series.

So my recommendation is to also go through Hummingbird TODO’s example, because that’s what I did to learn how to write this code. The main difference between that tutorial’s early steps and what I’ve done here is that I’ve uses swift-testing and that example still uses XCTests (at date published).

That tutorial goes on to add persistence through a postgres database, which I haven’t here. I’ll mention some options beside postgres in the Repository section

Hummingbird makes it incredibly easy to return JSON for Encodables.

But to do the standard crud functions on a collection of that type, there are some things to do!

This code is available as a swift Package at:

The Type

My type is a Clown, and it conforms to quite a few things.

public struct Clown: Decodable, ResponseEncodable, Sendable, Equatable {
  let id: Int
  var name: String
  var spareNoses: Int
}

The Repository

In Hummingbird examples the data store is generally referred to as a repository (although I called my repository ClownCar).

This type manages the collection of Clowns I’ll be editing. The API controller never touches the underlying storage itself, everything goes through a repository’s interface.

Right now my store is all in memory. Hopefully a future article will cover persistence between server runs, but I’ll list some of the options:

actor ClownCar {
  var clowns: [Int: Clown]

  init() {
    clowns = [
      0: Clown(id: 0, name: "Pagliacci", spareNoses: 0),
      1: Clown(id: 1, name: "Joseph Grimaldi", spareNoses: 41417),
      4: Clown(id: 4, name: "Weary Willie", spareNoses: 1),
      //Clown(id: 224111, name:"Bozo" , spareNoses: 4141),
    ]
  }

  // CREATE

  /// create a clown.
  func create(name: String, spareNoses: Int = 10) async throws -> Clown {
    let id = Int.random(in: 1000..<10000)
    let clown = Clown(id: id, name: name, spareNoses: spareNoses)
    self.clowns[id] = clown
    return clown
  }

  // READ

  /// Get clown
  func get(id: Int) async throws -> Clown? {
    return self.clowns[id]
  }
  /// List all clowns
  func list() async throws -> [Clown] {
    return self.clowns.values.map { $0 }
  }

  //UPDATE

  /// Update clown. Returns updated clown if successful
  // note all the optional fields...
  func update(id: Int, name: String?, spareNoses: Int?) async throws -> Clown? {
    if var clown = self.clowns[id] {
      if let name {
        clown.name = name
      }
      if let spareNoses {
        clown.spareNoses = spareNoses
      }
      self.clowns[id] = clown
      return clown
    }
    return nil
  }

  //DELETE

  /// Delete clown. Returns true if successful
  func delete(id: Int) async throws -> Bool {
    if self.clowns[id] != nil {
      self.clowns[id] = nil
      return true
    }
    return false
  }
  /// Delete all clowns
  func deleteAll() async throws {
    self.clowns = [:]
  }

}

The Controller & The Tests

This CRUD needs to be linked up to the API, so lets make a Controller type to glue the model to the data output “views.”

This Controller has three main jobs,

NOTE: With so many types being generic with respect to the Apps base context type its easier to change an apps context by providing a type alias.

import Hummingbird

#if canImport(FoundationEssentials)
  import FoundationEssentials
#else
  import Foundation
#endif

public struct ClownController: Sendable {
  // clown repository
  let repository: ClownCar

  // return clown endpoints
  var endpoints: RouteCollection<AppRequestContext> {
    let routes = RouteCollection(context: AppRequestContext.self)
    //routes.group(context: FlexContext.self)
    routes.group()
      .get(":id", use: get)
      .get(use: list)
      .post(use: create)
      .patch(":id", use: update)
      .patch("{id}/honk", use: noseDecrement)
      .delete(":id", use: delete)
      .delete(use: deleteAll)
    return routes
  }

  //implementations here...

}

Some of the handler functions have helper structs modeling the data that a client would be actually expected to include with the request, which may not be the same as the actual base type (Clown) the repository stores.

Each one of these can be tested with curl, but it’s easier in the long run to write the test with it at the same time, so I’ve paired those together below.

Do run through the tutorial’s testing section, but one can use my file as a base to switch to swift-testing. That tutorial is what suggest writing helper static functions at the top which is indeed very helpful.

I’ve wrapped the clown API tests in their own Suite, but the tests could be broken up further by tag (create, read, update, delete?) or other traits.

extension AppTests {
  @Suite("Tests to test the Clown's JSON API") struct ClownAPITests {
      //your tests here
  }
}

Also note in several places the tests look for .noContent instead of .notFound, this is because if a Clown? returns nil, Hummingbird’s default response is to return .noContent. No content is typically a success with no body, which it’s entirely possible for that nil to mean in some contexts. However, in my case that is the wrong response and if I stick with the Hummingbird handlers I will have to do something other than return a raw Clown?. Perfect future use of a Result? TBD.

Create

Controller

  struct CreateRequest: Decodable {
    let name: String
    let spareNoses: Int?
  }

  /// Create clown endpoint
  @Sendable func create(request: Request, context: some RequestContext) async throws
    -> EditedResponse<Clown>
  {
    let request = try await request.decode(as: CreateRequest.self, context: context)

    let clown = try await self.repository.create(
      name: request.name, spareNoses: request.spareNoses ?? 10)
    return EditedResponse(status: .created, response: clown)
  }

Tests

    struct CreateRequest: Encodable {
      let name: String
      let spareNoses: Int?
    }

    static func create(name: String, spareNoses: Int? = nil, client: some TestClientProtocol)
      async throws -> Clown
    {
      let request = CreateRequest(name: name, spareNoses: spareNoses)
      let buffer = try JSONEncoder().encodeAsByteBuffer(request, allocator: ByteBufferAllocator())
      return try await client.execute(uri: "/clowns", method: .post, body: buffer) { response in
        #expect(response.status == .created)
        return try JSONDecoder().decode(Clown.self, from: response.body)
      }
    }

    static func get(id: Int, client: some TestClientProtocol) async throws -> Clown? {
      try await client.execute(uri: "/clowns/\(id)", method: .get) { response in
        // either the get request returned an 200 status or it didn't return a Clown
        #expect(response.status == .ok || response.body.readableBytes == 0)
        if response.body.readableBytes > 0 {
          return try JSONDecoder().decode(Clown.self, from: response.body)
        } else {
          return nil
        }
      }
    }

    @Test func testCreate() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let clownName = "Bozo"
        let noseCount = 17
        let clown = try await Self.create(name: clownName, spareNoses: noseCount, client: client)
        #expect(clown.name == clownName)
        #expect(clown.spareNoses == noseCount)

        let retrievedClown = try await Self.get(id: clown.id, client: client)
        #expect(clown == retrievedClown)
      }
    }

Read

Controller

    /// Get clown endpoint
  @Sendable func get(request: Request, context: some RequestContext) async throws -> Clown? {
    let id = try context.parameters.require("id", as: Int.self)
    return try await self.repository.get(id: id)
  }

  /// Get list of clowns endpoint
  @Sendable func list(request: Request, context: some RequestContext) async throws -> [Clown] {
    return try await self.repository.list()
  }

Tests

    // used in Create, too
    static func get(id: Int, client: some TestClientProtocol) async throws -> Clown? {
    //...
    }

    static func list(client: some TestClientProtocol) async throws -> [Clown] {
      try await client.execute(uri: "/clowns", method: .get) { response in
        #expect(response.status == .ok)
        return try JSONDecoder().decode([Clown].self, from: response.body)
      }
    }


    @Test func testClownFetch() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in

        let goodID = 1  //start with known good.
        let _ = try await client.execute(uri: "/clowns/\(goodID)", method: .get) { response in
          #expect(response.status == .ok)

          #expect(response.headers.contains(.contentType))
          let contentType = response.headers[.contentType]!
          #expect(contentType == "application/json; charset=utf-8")

          let clown = try JSONDecoder().decode(Clown.self, from: response.body)
          //print(clown)
          //In "real" app would pull this directly from the db using same ID.
          let compareTo = Clown(id: 1, name: "Joseph Grimaldi", spareNoses: 12)
          #expect(clown.name == compareTo.name)
        }

        let badID = 53_253_622  //change to known unused value
        let _ = try await client.execute(uri: "/clowns/\(badID)", method: .get) { response in
          //bad interpretation of a nil
          #expect(response.status == .noContent)
        }

        let notAnID = "jieoGEJg"  //change to malformed
        let _ = try await client.execute(uri: "/clowns/\(notAnID)", method: .get) { response in
          #expect(response.status == .badRequest)
        }
      }
    }

    @Test func testClownsFetch() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        //this is weak. If the test data, not currently managed by the test suite,
        //changes, these test could fail based on that.
        let goodClown = Clown(id: 1, name: "Joseph Grimaldi", spareNoses: 41417)
        let badClown = Clown(id: -1, name: "Pennywise", spareNoses: 86_428_916_419)
        let _ = try await client.execute(uri: "/clowns/", method: .get) { response in
          #expect(response.status == .ok)

          #expect(response.headers.contains(.contentType))
          let contentType = response.headers[.contentType]!
          #expect(contentType == "application/json; charset=utf-8")

          let clowns = try JSONDecoder().decode([Clown].self, from: response.body)
          #expect(clowns.contains(where: { $0 == goodClown }))
          #expect(!clowns.contains(where: { $0 == badClown }))
        }
      }
    }

Update

This update example highlights that the closure can pull data out of multiple places, the URL and the various fields of the Request.

let id = try context.parameters.require("id", as: Int.self)

pulls the id number out of the URL.

let requestValue = try await request.decode(as: UpdateRequest.self, context: context)

Uses the request’s decode function in conjunction with decoder from the context to try to get the JSON data in the request body to match the specified UpdateRequest

Controller

  struct UpdateRequest: Decodable {
    let name: String?
    let spareNoses: Int?
  }
  /// Update clown endpoint
  @Sendable func update(request: Request, context: some RequestContext) async throws -> Clown? {
    let id = try context.parameters.require("id", as: Int.self)
    let requestValue = try await request.decode(as: UpdateRequest.self, context: context)
    guard
      let clown = try await self.repository.update(
        id: id,
        name: requestValue.name,
        spareNoses: requestValue.spareNoses
      )
    else {
      throw HTTPError(.badRequest)
    }
    return clown
  }

  //custom update function
  @Sendable func noseDecrement(request: Request, context: some RequestContext) async throws
    -> Clown?
  {
    let honkRequestID = try context.parameters.require("id", as: Int.self)
    if let beforeClown = try await self.repository.get(id: honkRequestID) {
      return try await self.repository.update(
        id: honkRequestID, name: nil, spareNoses: beforeClown.spareNoses - 1)
    }
    return nil
  }

Tests


    //also uses the Self.get 

    struct UpdateRequest: Encodable {
      let name: String?
      let spareNoses: Int?
      let completed: Bool?
    }

    static func patch(
      id: Int, name: String? = nil, spareNoses: Int? = nil, completed: Bool? = nil,
      client: some TestClientProtocol
    ) async throws -> Clown? {
      let request = UpdateRequest(name: name, spareNoses: spareNoses, completed: completed)
      let buffer = try JSONEncoder().encodeAsByteBuffer(request, allocator: ByteBufferAllocator())
      return try await client.execute(uri: "/clowns/\(id)", method: .patch, body: buffer) {
        response in
        #expect(response.status == .ok, "\(response.status)")
        if response.body.readableBytes > 0 {
          return try JSONDecoder().decode(Clown.self, from: response.body)
        } else {
          return nil
        }
      }
    }
    //
    //    //for testing uuid is just a string so can pass garbage later.
    static func patchResponseStatus(
      id: String, name: String? = nil, spareNoses: Int? = nil, completed: Bool? = nil,
      client: some TestClientProtocol
    ) async throws -> HTTPResponse.Status {
      let request = UpdateRequest(name: name, spareNoses: spareNoses, completed: completed)
      let buffer = try JSONEncoder().encodeAsByteBuffer(request, allocator: ByteBufferAllocator())
      return try await client.execute(uri: "/clowns/\(id)", method: .patch, body: buffer) {
        response in
        response.status
      }
    }

    @Test func testUpdateBoth() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let knownGoodID = 4
        let newName = "Bozo Jr."
        let newNoseCount = 18
        let beforeClown = try await Self.get(id: knownGoodID, client: client)
        let clown = try await Self.patch(
          id: knownGoodID, name: newName, spareNoses: newNoseCount, client: client)
        #expect(clown != nil)
        #expect(clown!.name == newName)
        #expect(clown!.spareNoses == newNoseCount)
        #expect(clown!.name != beforeClown?.name)
        #expect(clown!.spareNoses != beforeClown?.spareNoses)
        #expect(clown!.id == beforeClown?.id)

        let retrievedClown = try await Self.get(id: clown!.id, client: client)
        #expect(clown == retrievedClown)
        let retrievedOrigIDClown = try await Self.get(id: knownGoodID, client: client)
        #expect(retrievedClown == retrievedOrigIDClown)
      }
    }

    @Test func testUpdateBadId() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let knownBadID = 890_379_023
        let newName = "Bozo Jr."
        let newNoseCount = 18
        let _ = try await client.execute(uri: "/clowns/\(knownBadID)", method: .get) { response in
          //bad interpretation of a nil
          #expect(response.status == .noContent)
        }
        var updateStatus = try await Self.patchResponseStatus(
          id: "\(knownBadID)", name: newName, spareNoses: newNoseCount, client: client)
        #expect(updateStatus == .badRequest)

        updateStatus = try await Self.patchResponseStatus(
          id: "fgu8ik56gw3R)", name: newName, spareNoses: newNoseCount, client: client)
        #expect(updateStatus == .badRequest)

      }
    }

    @Test func testNoseDecrement() async throws {
        let app = try await buildApplication(TestArguments())

        try await app.test(.router) { client in
          let knownGoodID = 1
          let beforeClown = try await Self.get(id: knownGoodID, client: client)
          #expect(beforeClown != nil)
          let replyClown:Clown? = try await client.execute(uri: "clowns/\(knownGoodID)/honk", method: .patch) { response in 
            if response.body.readableBytes > 0 {
            //print(String(buffer: response.body))
            return try JSONDecoder().decode(Clown.self, from: response.body)
            } else {
            return nil
            }
        }
        
          #expect(replyClown != nil)
          #expect(replyClown!.name == beforeClown?.name )
          #expect(replyClown!.spareNoses != beforeClown?.spareNoses)
          #expect(replyClown!.spareNoses + 1 == beforeClown?.spareNoses)
          #expect(replyClown!.id == beforeClown?.id)
        }
    }

    @Test func testUpdateName() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let knownGoodID = 4
        let newName = "Bozo Jr."
        let beforeClown = try await Self.get(id: knownGoodID, client: client)
        let clown = try await Self.patch(id: knownGoodID, name: newName, client: client)
        #expect(clown != nil)
        #expect(clown!.name == newName)
        #expect(clown!.name != beforeClown?.name)
        #expect(clown!.spareNoses == beforeClown?.spareNoses)
        #expect(clown!.id == beforeClown?.id)

        let retrievedClown = try await Self.get(id: clown!.id, client: client)
        #expect(clown == retrievedClown)
        let retrievedOrigIDClown = try await Self.get(id: knownGoodID, client: client)
        #expect(retrievedClown == retrievedOrigIDClown)
      }
    }

Delete

Controller

  /// Delete clown endpoint
  @Sendable func delete(request: Request, context: some RequestContext) async throws
    -> HTTPResponse.Status
  {
    let id = try context.parameters.require("id", as: Int.self)
    if try await self.repository.delete(id: id) {
      return .ok
    } else {
      return .badRequest
    }
  }

  /// Delete all clowns endpoint
  @Sendable func deleteAll(request: Request, context: some RequestContext) async throws
    -> HTTPResponse.Status
  {
    try await self.repository.deleteAll()
    return .ok
  }

Tests

    // also uses Self.get

    static func delete(id: Int, client: some TestClientProtocol) async throws -> HTTPResponse.Status
    {
      try await client.execute(uri: "/clowns/\(id)", method: .delete) { response in
        response.status
      }
    }

    static func deleteAll(client: some TestClientProtocol) async throws -> HTTPResponse.Status {
      try await client.execute(uri: "/clowns", method: .delete) { response in
        response.status
      }
    }

    @Test func testDelete() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let knownGoodID = 4
        let beforeClown = try await Self.get(id: knownGoodID, client: client)
        #expect(beforeClown != nil)
        let status = try await Self.delete(id: knownGoodID, client: client)
        #expect(status == .ok)
        try await client.execute(uri: "/clowns/\(knownGoodID)", method: .get) { response in
          //bad interpretation of a nil
          #expect(response.status == .noContent)
        }
        try await client.execute(uri: "/clowns/\(knownGoodID)", method: .delete) { response in
          #expect(response.status == .badRequest)
        }
      }

    }

    @Test func testDeleteAll() async throws {
      let app = try await buildApplication(TestArguments())

      try await app.test(.router) { client in
        let knownGoodID = 4
        let beforeClown = try await Self.get(id: knownGoodID, client: client)
        #expect(beforeClown != nil)
        let status = try await Self.deleteAll(client: client)
        #expect(status == .ok)
        try await client.execute(uri: "/clowns/\(knownGoodID)", method: .get) { response in
          //bad interpretation of a nil
          #expect(response.status == .noContent)
        }
        try await client.execute(uri: "/clowns/\(knownGoodID)", method: .delete) { response in
          #expect(response.status == .badRequest)
        }

        let deletedClowns = try await Self.list(client: client)
        #expect(deletedClowns.isEmpty)
      }
    }

Left to do

I would rate this controller as “needs improvement”, especially on the error handling. Everywhere there’s a

throw HTTPError(.badRequest)

for example, there ought to be a message providing more data.

throw HTTPError(.badRequest, message: "this this you sent didn't make it through")

When a call like

let honkRequestID = try context.parameters.require("id", as: Int.self)

fails, Hummingbird will catch the error and wrap it in it’s own preferred HTTPStatus with an informative response body. I should add my own

When trying to match an API that doesn’t use the same response statuses as the Hummingbird defaults, it may mean catching that error in the closure and implementing your own Error -> HTTPResponse handling.

Also, where is the Authentication and Authorization layer? Can anyone delete anything??!!!

There is definitely more to do.

Summary

It takes a bit of fiddling to implement a CRUD the first time in Hummingbird, but I’ve written a couple of others already mostly by copy-pasting and searching for my Type name. Seems like a potential macro or something could be written, @CRUDable, ha! To do that correctly would pretty much be reimplementing SwiftData so perhaps not this week.

A way to make writing API code that’s very enforceable across projects is to use the OpenAPI plugin for Swift packages.

In the next post we’ll start the process of implementing the Clown JSON API with that.

This article is part of a series.