We have a domain model, a persistence layer, and a CI pipeline that keeps things honest. But task-cluster can’t do anything useful yet — it has no way to accept requests. In this post, we will take a massive step towards that. We’ll design an OpenAPI specification, wire up Apple’s swift-openapi-generator to produce type-safe server stubs, implement a Hummingbird controller that connects those stubs to the repository we built in part two, and then test the whole thing with mocked repositories.
This is where the contract-first approach from the first post starts to pay off. The OpenAPI spec becomes the single source of truth for the API, and the generated code enforces that contract at compile time. If the spec says createTask returns a 201, your controller must return a .created(...) response — not because you remembered to, but because the compiler won’t accept anything else.
Designing the OpenAPI Spec
Before writing any Swift code, we need to define the API contract. The spec lives at the project root as openapi.yaml and describes everything a client needs to know: what endpoints exist, what they accept, what they return, and what errors they produce.
For a task management service, the operations are straightforward: create a task, retrieve it by ID, update its priority, and cancel it. Let’s walk through the design.
Create an
openapi.yamlfile at the project root. Define an OpenAPI 3.1 spec for a task management API with four operations, all taggedTasks:
POST /task— create a task (201 on success, 400 for invalid input)GET /task/{taskId}— get a task by ID (200 on success, 404 if not found)PATCH /task/{taskId}/priority— update a task’s priority (200 on success, 400 for invalid priority, 404 if not found)POST /task/{taskId}/cancel— cancel a task (200 on success, 404 if not found, 409 if task can’t be cancelled)Define schemas for
TaskStatus(string enum matching our domain model),CreateTaskRequest,UpdatePriorityRequest, andTaskResponse. ThetaskIdpath parameter should be a string with UUID format. Priority should be an integer from 1 to 10.
You can take a look at full spec here: https://github.com/tachyonics/task-cluster/blob/9bb537341897ce66e0f3280c72cd487fdf002e99/openapi.yaml
Design decisions
Separate request and response schemas. The CreateTaskRequest only includes the fields a client provides (title, description, priority, dueBy). The TaskResponse includes server-generated fields too (taskId, status, timestamps). This makes the contract explicit about what flows in each direction.
UUID as string with format: uuid. OpenAPI doesn’t have a native UUID type. The format: uuid hint is for documentation and client code generators that understand it. In swift-openapi-generator, this maps to Swift.String, not Foundation.UUID. We’ll handle the parsing ourselves in the controller — more on that shortly.
Priority as integer with minimum/maximum. The spec declares the valid range (1-10), which is useful for documentation and client-side validation. However, swift-openapi-generator doesn’t enforce these constraints at the server level — the generated type is just Swift.Int. We’ll validate in the controller. This is a reasonable separation: the spec documents the contract, the server enforces it.
409 Conflict for cancel. Cancelling a task that’s already completed, failed, or cancelled is a conflict rather than a bad request. The request itself is well-formed — it’s the current state of the resource that prevents the operation. This distinction matters for clients that need to handle the error differently.
Tags for filtering. Every operation has tags: [Tasks]. This seems redundant now when we only have one API domain, but it becomes important when the spec grows. If we later add user management or scheduling operations, each domain gets its own tag, and each code generation target can filter to just the operations it cares about. Setting this up now avoids restructuring later.
Setting Up swift-openapi-generator
With the spec written, we need to tell Swift how to generate code from it. Apple’s swift-openapi-generator runs as an SPM build plugin — it reads the spec at compile time and produces type-safe Swift code. No generated files in your repository, no manual regeneration step. Change the spec and the types update on the next build.
The setup involves three things: adding the dependencies to Package.swift, creating a target to host the generated code, and configuring the generator.
Add the following package dependencies:
swift-openapi-generator(from 1.6.0),swift-openapi-runtime(from 1.7.0),swift-openapi-hummingbird(from 2.0.1) and hummingbird (from 2.0.0). Create aTaskAPItarget that depends onOpenAPIRuntimeand uses theOpenAPIGeneratorbuild plugin. The target needs anopenapi.yaml(symlinked from the project root), anopenapi-generator-config.yamlthat generates types and server stubs, uses package access, and filters to only theTaskstag, and an empty placeholder Swift file to silence the SPM “no source files” warning.
First, the new dependencies in Package.swift:
.package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.6.0"),.package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.7.0"),.package(url: "https://github.com/swift-server/swift-openapi-hummingbird.git", from: "2.0.1"),
Then the target:
.target( name: "TaskAPI", dependencies: [ .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), ], plugins: [ .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"), ]),
The TaskAPI target only depends on OpenAPIRuntime — the lightweight types that the generated code references at runtime (things like OpenAPIRuntime.HTTPBody). The generator itself runs as a build plugin and doesn’t need to be a runtime dependency.
The generator configuration
The build plugin looks for two files in the target’s source directory: an openapi.yaml spec and an openapi-generator-config.yaml that tells it what to generate.
For the spec, rather than copying openapi.yaml into the target directory, we create a symlink:
cd Sources/TaskAPIln -s ../../openapi.yaml openapi.yaml
This keeps a single source of truth. The spec at the project root is the canonical version; the target just points to it.
The configuration file tells the generator what kind of code to produce:
generate: - types - serveraccessModifier: packagefilter: tags: - Tasks
Let’s unpack each line:
generate: [types, server] produces the schema types (Components.Schemas.TaskResponse, Components.Schemas.CreateTaskRequest, etc.) and server stubs (the APIProtocol with a method for each operation). We don’t generate client here because this is the server — a separate target in a consuming service would generate client code from the same spec.
accessModifier: package makes the generated types visible across targets within the same Swift package, but not to external consumers. Since TaskClusterApp (which implements the controller) and TaskAPI (which defines the generated protocol) are both in the same package, package access is sufficient. If we were publishing TaskAPI as a library for other packages to import, we’d use public.
filter: tags: [Tasks] is where the tag convention pays off. The generator only produces code for operations tagged Tasks. If the spec later includes operations with other tags, they won’t appear in this target’s generated code. This pattern scales to multiple API domains: each gets its own target, its own tag filter, and its own generated protocol. A UserAPI target might filter on Users, generating a completely separate APIProtocol that a different controller implements.
The target will build without any Swift source files — the build plugin generates everything — but SPM will emit a warning about no source files being found under the target directory. This is currently a minor limitation of the Swift compiler and adding an empty placeholder silences it:
// Sources/TaskAPI/TaskAPI.swift// This file is intentionally empty.// The OpenAPI generator build plugin produces all API types at compile time.
What Gets Generated
With the spec and configuration in place, building the project will trigger the plugin and generate the Swift types. Let’s build now to verify the setup works:
Build the
TaskAPItarget to verify the OpenAPI generator plugin is configured correctly. Then look at the generated source files in the build directory to understand what the plugin produces.
You won’t see the generated files in your source tree — they’re written into the build directory — but understanding what they contain is essential for writing the controller.
APIProtocol is a Swift protocol with one method per operation. For our spec, it looks roughly like:
package protocol APIProtocol: Sendable { func createTask(_ input: Operations.createTask.Input) async throws -> Operations.createTask.Output func getTask(_ input: Operations.getTask.Input) async throws -> Operations.getTask.Output func updateTaskPriority(_ input: Operations.updateTaskPriority.Input) async throws -> Operations.updateTaskPriority.Output func cancelTask(_ input: Operations.cancelTask.Input) async throws -> Operations.cancelTask.Output}
Each method takes a strongly-typed Input and returns a strongly-typed Output. There’s no stringly-typed routing, no manual JSON parsing, no raw HTTPRequest to pick apart.
Operations.* namespaces contain the input and output types for each operation. The input wraps path parameters, query parameters, headers, and the request body. The output is an enum of HTTP status codes — this is one of the cleverest parts of the design. For createTask, the output is something like:
enum Output: Sendable { case created(Operations.createTask.Output.Created) case badRequest(Operations.createTask.Output.BadRequest) // ...}
You must return one of the defined response types. If the spec says the endpoint can return 201 or 400, those are the only cases available. You can’t accidentally return a 500 or a 302 — the compiler won’t let you. And if you add a new response code to the spec, the compiler will tell you everywhere that needs to handle it.
Components.Schemas.* contains the schema types. CreateTaskRequest becomes a struct with properties matching the schema. TaskStatus becomes a string-backed enum. These are Codable by default, with JSON serialisation handled by the runtime.
Type mapping gotchas
Two mappings that will affect our controller:
string with format: uuid maps to Swift.String, not Foundation.UUID. The generator doesn’t parse the string into a UUID for you — it’s still a raw string at the Swift level. We’ll need to convert it manually with UUID(uuidString:) in the controller and handle the case where the string isn’t a valid UUID.
integer maps to Swift.Int, not Int32. This is convenient since our domain model also uses Int for priority — no type conversion needed there.
The TaskClusterApp Target
The generated APIProtocol is framework-agnostic — it defines the contract, but doesn’t know how to receive HTTP requests. The swift-openapi-hummingbird package bridges this gap. It provides a registerHandlers(on:) method that takes an APIProtocol conformance and a Hummingbird router, and wires each generated operation to the corresponding HTTP route.
We need a new target that brings everything together: the generated types from TaskAPI, the domain model from TaskClusterModel, and Hummingbird for the HTTP layer.
Create a
TaskClusterApptarget that depends onTaskAPI,TaskClusterModel,Hummingbird,OpenAPIRuntime, andOpenAPIHummingbird. This target will contain the controller implementation and the application builder (don’t create these yet, don’t need to create a placeholder though).
.target( name: "TaskClusterApp", dependencies: [ "TaskAPI", "TaskClusterModel", .product(name: "Hummingbird", package: "hummingbird"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIHummingbird", package: "swift-openapi-hummingbird"), ]),
The dependency graph is now layered:
TaskClusterApp├── TaskAPI (generated APIProtocol + types)│ └── OpenAPIRuntime├── TaskClusterModel (domain types + repository protocol)├── Hummingbird (HTTP framework)└── OpenAPIHummingbird (bridges OpenAPI → Hummingbird)
TaskClusterModel still has zero external dependencies. TaskAPI depends only on the OpenAPI runtime. The framework and integration concerns live in TaskClusterApp, which is the only target that knows about Hummingbird.
Implementing the Controller
Now the interesting part. The TaskController conforms to the generated APIProtocol and implements each operation by delegating to the repository.
Create
Sources/TaskClusterApp/TaskController.swift. Define aTaskControllerstruct generic overRepository: TaskRepositorythat conforms to the generatedAPIProtocolfromTaskAPI. Look at the generated source files in the build directory to understand the method signatures, input/output types, and schema types you need to work with. Implement all four operations:
createTask— extract the JSON body, validate priority is 1-10, create aTaskItemwith status.pending, save via the repository, return.createdgetTask— parse thetaskIdpath parameter fromStringtoUUID(the generated type isString, notUUID), look up via the repository, return.okor.notFoundupdateTaskPriority— parse taskId, validate priority, fetch the task, update it, return.ok,.badRequest, or.notFoundcancelTask— parse taskId, fetch the task, check status is.pendingor.running(return.conflictotherwise), set status to.cancelled, save, return.okAdd a
toResponse()extension onTaskItemthat converts toComponents.Schemas.TaskResponse. You can use let body = switch input.body { case .json(let value): value } to get the body from the request enum in a single line.
You can find the full source here: https://github.com/tachyonics/task-cluster/blob/9bb537341897ce66e0f3280c72cd487fdf002e99/Sources/TaskClusterApp/TaskController.swift
Patterns worth noting
Generic over the repository. The controller is TaskController<Repository: TaskRepository> rather than taking a concrete type or an existential any TaskRepository. This means the repository type is known at compile time, enabling the compiler to specialise the code for the specific implementation. In practice, you’ll get TaskController<MockTestTaskRepository> for testing and TaskController<DynamoDBTaskRepository<...>> for execution. The tradeoff is a slightly more verbose type signature in exchange for zero runtime dispatch overhead while enabling testing for this code.
An existential (any TaskRepository) is a box that can hold any concrete type conforming to a protocol. The box has a fixed size and dispatches method calls dynamically at runtime. This adds a level of indirection — each call goes through a witness table lookup — and prevents the compiler from specialising code for the concrete type inside. Generics (some TaskRepository or <Repository: TaskRepository>) avoid this by resolving the concrete type at compile time.
Body extraction. The generated input wraps the request body in an enum to support multiple content types (a spec could accept both JSON and XML, for example). Since our spec only defines application/json, the enum has a single case. The switch input.body { case .json(let value): value } pattern extracts it in one expression. Swift’s exhaustive switch checking means if the spec later adds another content type, the compiler will flag every extraction point that needs updating.
UUID parsing. Because format: uuid maps to String, we parse it manually with UUID(uuidString:). An invalid UUID gets treated as a not-found response — the task can’t exist if its ID isn’t even a valid UUID. You could argue this should be a 400 Bad Request instead. Either is defensible; we’re choosing the simpler approach of treating any unresolvable ID as a miss.
Priority validation. Despite the spec declaring minimum: 1 and maximum: 10, we validate in the controller. Server-side validation is non-negotiable — the spec constraints are advisory documentation for clients, but a malicious or buggy client could send anything. The (1...10).contains(body.priority) check is the actual enforcement.
Cancellation guard. The cancelTask method checks that the task is either pending or running before allowing cancellation. A completed, failed, or already-cancelled task returns 409 Conflict. This is a domain rule enforced in the controller — the repository doesn’t know about valid state transitions, it just stores whatever it’s given. Keeping business logic in the controller rather than the repository makes the rules visible and testable at the API level.
Domain to API conversion
The controller needs to convert between domain types (TaskItem) and API types (Components.Schemas.TaskResponse). These are structurally similar but not the same type — one lives in TaskClusterModel with no external dependencies, and the other is generated by the OpenAPI plugin with its own serialisation requirements.
extension TaskItem { func toResponse() -> Components.Schemas.TaskResponse { .init( taskId: taskId.uuidString, title: title, description: description, priority: priority, status: Components.Schemas.TaskStatus(rawValue: status.rawValue)!, dueBy: dueBy, createdAt: createdAt, updatedAt: updatedAt ) }}
A couple of conversions happen here. The taskId goes from UUID to String via uuidString. The status converts between our domain TaskStatus enum and the generated Components.Schemas.TaskStatus enum — since both are string-backed with identical raw values, the conversion via rawValue is safe. The force unwrap (!) is justified because the raw values are guaranteed to match by construction: both enums are derived from the same set of status strings. If someone adds a status to the domain model without updating the spec (or vice versa), the mismatch would manifest as a crash here, which is the correct behaviour — it’s a programming error, not a runtime condition to handle gracefully.
Everything else maps directly: Int to Int, String to String, Date? to Date?. The generated types handle date formatting (ISO 8601) automatically during JSON serialisation.
Building the Application
The controller implements the generated protocol, but we still need to connect it to an HTTP server. This wiring — creating a Hummingbird router, registering the OpenAPI handlers, and wrapping it in an application — lives in a buildApplication function. We’ll use this function both for running the service (in the next post) and for testing the controller now.
Create
Sources/TaskClusterApp/Application+build.swift. Define abuildApplicationfunction that accepts a repository, an application configuration, and a logger, and returns an application. Build a router with request logging middleware, a health check endpoint, and the task controller’s handlers registered.
You can find the source for buildApplication here: https://github.com/tachyonics/task-cluster/blob/9bb537341897ce66e0f3280c72cd487fdf002e99/Sources/TaskClusterApp/Application%2Bbuild.swift
The critical line is taskController.registerHandlers(on: router). This method is generated by swift-openapi-hummingbird and does exactly what it sounds like: for each operation in the APIProtocol, it registers an HTTP route on the Hummingbird router with the correct method, path, and content negotiation. POST /task gets wired to createTask, GET /task/{taskId} to getTask, and so on. The glue code that would normally be the most tedious part of building an API — route registration, request parsing, response serialisation — is handled entirely by the generated integration.
Note that buildApplication returns some ApplicationProtocol — an opaque type. Callers know they get something they can run (or test against), but the concrete type is an implementation detail. The function also throws because registerHandlers can fail (for example, if two operations have conflicting routes).
The buildApplication function takes the repository as some TaskRepository, using an opaque parameter. This is the same generics-under-the-hood mechanism as the controller’s generic parameter — the concrete type is preserved through the call chain, so the compiler can specialise the entire path from request to repository without any existential boxing.
LogRequestsMiddleware(.info) logs every incoming request at info level, which is useful during development. The health check is minimal but important — it gives load balancers and orchestrators something to probe.
Testing the Controller
The controller is where the business logic lives — validation, state transitions, error responses — and we want tests that verify each of these decisions in isolation. The repository protocol makes this straightforward: rather than hitting a real database, we inject a mock that lets us control exactly what the repository returns and verify exactly what the controller asks it to do.
Add https://github.com/tachyonics/smockable (from 1.0.0-alpha.1) as a package dependency and create a
TaskClusterTeststest target that depends onTaskClusterApp,TaskClusterModel,Smockable,Hummingbird, andHummingbirdTesting.Create a test file with a shadow protocol that inherits from
TaskRepositoryand redeclares the same methods, annotated with@Smockto generate a mock implementation. Write unit tests for theTaskControllerthat usebuildApplicationwith the mock repository and test through the Hummingbird router withapp.test(.router). Cover:
- Creating a task with valid input returns 201
- Getting a non-existent task returns 404
- Updating priority with an out-of-range value returns 400 without touching the repository
- Cancelling a completed task returns 409
- Cancelling a pending task succeeds and sets status to cancelled
The test target setup in Package.swift:
.testTarget( name: "TaskClusterTests", dependencies: [ "TaskClusterApp", "TaskClusterModel", .product(name: "Smockable", package: "smockable"), .product(name: "Hummingbird", package: "hummingbird"), .product(name: "HummingbirdTesting", package: "hummingbird"), ]),
The shadow protocol
Smockable generates mock types from protocols annotated with @Smock. Since TaskRepository is defined in TaskClusterModel and we don’t want Smockable as a production dependency, we use a shadow protocol in the test target — a protocol that inherits from TaskRepository and redeclares its methods so @Smock can see them.
This again is a small limitation of Swift’s macros – macros do not have access to the definition of inherited types/protocol so the workaround is to re-declare any protocol requirements here:
@Smockprotocol TestTaskRepository: TaskRepository { func create(task: TaskItem) async throws -> TaskItem func get(taskId: UUID) async throws -> TaskItem? func update(task: TaskItem) async throws -> TaskItem}
This generates MockTestTaskRepository, which conforms to TaskRepository and can be injected into buildApplication.
What the tests look like
Each test follows the same structure: set up expectations on the mock, build an application with the mock as the repository, send an HTTP request through the Hummingbird test client, assert on the response, and verify the mock interactions. Here’s the create task test to illustrate the pattern:
@Suite("TaskController unit tests")struct TaskControllerTests { @Test("Create task succeeds with valid input") func createTaskSuccess() async throws { var expectations = MockTestTaskRepository.Expectations() when(expectations.create(task: .any), use: { task in return task }) let mock = MockTestTaskRepository(expectations: expectations) let app = try buildApplication(repository: mock) try await app.test(.router) { client in try await client.execute( uri: "/task", method: .post, body: ByteBuffer(string: #"{"title":"Test task","priority":5}"#) ) { response in #expect(response.status == .created) let task = try JSONDecoder.appDecoder.decode(TaskItem.self, from: response.body) #expect(task.title == "Test task") #expect(task.priority == 5) #expect(task.status == .pending) } } @Sendable func isExpectedTask(_ task: TaskItem) -> Bool { task.title == "Test task" && task.priority == 5 && task.status == .pending } InOrder(strict: true, mock) { inOrder in inOrder.verify(mock).create(task: .matching(isExpectedTask)) } }}
The key pieces:
Expectations. when(expectations.create(task: .any), use: { task in return task }) tells the mock: “when create is called with any task, return the task unchanged.” Smockable provides value matchers — .any, .exact(value), and .matching(closure) — for flexible argument matching.
The test client. app.test(.router) is Hummingbird’s built-in test harness. The .router mode runs the request through the router pipeline without starting a real HTTP server — no port binding, no network I/O, just the middleware and handler chain. This means tests exercise the full pipeline — route matching, content negotiation, JSON deserialisation, the controller logic, and JSON serialisation of the response — while remaining fast and parallelisable.
Verification. InOrder(strict: true, mock) checks that methods were called in the exact order specified, with no unexpected calls. For the validation test, verifyNoInteractions(mock) proves the controller rejects invalid input before touching the repository — a powerful pattern for testing the order of operations by asserting on what wasn’t called.
The tests use Apple’s Swift Testing framework (@Suite, @Test, #expect) rather than XCTest. Swift Testing integrates well with structured concurrency — test methods can be async throws natively — and the #expect macro produces better diagnostics than XCTAssertEqual on failure.
Run the tests with:
swift test --filter TaskClusterTests
What We Built
The project structure now looks like this:
Sources/ TaskClusterModel/ TaskItem.swift # Domain model TaskStatus.swift # Status enum TaskRepository.swift # Repository protocol TaskClusterDynamoDBModel/ DynamoDBTaskRepository.swift # DynamoDB implementation TaskAPI/ openapi.yaml # → symlink to root spec openapi-generator-config.yaml # Generator configuration TaskAPI.swift # Placeholder (generated code is invisible) TaskClusterApp/ TaskController.swift # APIProtocol implementation Application+build.swift # Application builder + routerTests/ TaskClusterTests/ TaskControllerTests.swift # 5 unit tests with mocked repository
The OpenAPI spec is the contract. The build plugin generates the protocol and types. The controller implements the protocol. The router wires it to HTTP. The repository stores the data. And the tests verify all the business logic through mocks that let us control and observe every interaction with the data layer.
We haven’t written a single line of JSON parsing code, URL routing code, or HTTP response construction code. The OpenAPI toolchain handles all of that. What we wrote is business logic — validation, state transitions, domain model conversion — and tests that verify it. That’s a good ratio.
What’s Next
We have now a tested API layer. We can’t run the service yet but we are very close. In the next post, we will wire everything up together so that we can, including an executable entry point with swift-configuration for flexible runtime configuration, and get the service responding to real HTTP requests. We’ll also add integration tests that exercise the full stack — from HTTP request through the controller to a real repository and back.
Leave a comment