Skip to content

10 · Capstone Project

This capstone combines nearly every module from Levels 3 and 4 into one working service: a signed, authenticated REST API backed by SQLite, with structured logging and environment-driven configuration, built on the service-layer architecture from Module 09. Every piece below is real code that compiles and runs, exercised end to end with curl against the actual listening server.

What it pulls together

Piece From
HTTP server on Network.framework Level 3, Module 10
SQLite persistence via prepared statements Level 3, Module 03
Structured logging + environment config Level 4, Module 02
HMAC request signing for write authorization Level 4, Module 04
Protocol-fronted service layer Level 4, Module 09

Project layout

capstone/
├── Package.swift
└── Sources/
    └── capstone/
        └── main.swift
// Package.swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "capstone",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0")
    ],
    targets: [
        .executableTarget(name: "capstone", dependencies: [.product(name: "Logging", package: "swift-log")])
    ]
)

Configuration and the model/database layer

Config reads from the environment (Module 02) and derives a signing key from an API_KEY environment variable:

import Foundation
import CryptoKit

struct Config {
    let port: UInt16
    let environment: String
    let apiKey: SymmetricKey

    static func fromEnvironment() -> Config {
        let port = UInt16(ProcessInfo.processInfo.environment["PORT"] ?? "") ?? 8124
        let env = ProcessInfo.processInfo.environment["APP_ENV"] ?? "development"
        let keyString = ProcessInfo.processInfo.environment["API_KEY"] ?? "demo-secret-key"
        let key = SymmetricKey(data: SHA256.hash(data: Data(keyString.utf8)))
        return Config(port: port, environment: env, apiKey: key)
    }
}

SymmetricKey(data: SHA256.hash(...)) derives a fixed-length key from an arbitrary-length passphrase — not a proper KDF (Module 04 mentions HKDF for that), but enough to turn any API_KEY string into a usable 256-bit key for this demo.

The database and service layer follow the exact prepared-statement and protocol-fronted patterns from Level 3 Module 03 and Level 4 Module 09:

struct Note {
    let id: Int
    let text: String
}

protocol NoteStoring {
    func fetchAll() -> [Note]
    func add(text: String) -> Note
}

final class SQLiteNoteStore: NoteStoring {
    private let db: NoteDatabase
    init(db: NoteDatabase) { self.db = db }
    func fetchAll() -> [Note] { db.all() }
    func add(text: String) -> Note {
        let id = db.insert(text: text)
        return Note(id: id, text: text)
    }
}

(NoteDatabase itself is the same sqlite3_prepare_v2/sqlite3_step shape as Level 3 Module 03's EmployeeDatabase — omitted here for length; see that module for the full implementation.)

Authorizing writes with HMAC

Every POST must carry an X-Signature header — an HMAC-SHA256 over the raw request body, using the server's configured key (Module 04):

func isAuthorized(headerValue: String?, body: String, key: SymmetricKey) -> Bool {
    guard let headerValue else { return false }
    guard let providedData = Data(hexString: headerValue) else { return false }
    return HMAC<SHA256>.isValidAuthenticationCode(providedData, authenticating: Data(body.utf8), using: key)
}

Using HMAC.isValidAuthenticationCode (rather than recomputing a hex string and comparing with ==) means the comparison itself is constant-time, exactly the concern Module 04 raised about hand-rolled secret comparisons.

The route handler

switch (method, path) {
case ("GET", "/notes"):
    responseBody = jsonArray(store.fetchAll())
    logger.info("Handled request", metadata: ["method": "GET", "path": "/notes"])
case ("POST", "/notes"):
    guard isAuthorized(headerValue: signatureHeader, body: body, key: config.apiKey) else {
        status = "401 Unauthorized"
        responseBody = "{\"error\":\"invalid or missing signature\"}"
        logger.warning("Rejected unauthorized write", metadata: ["path": "/notes"])
        break
    }
    let fields = parseSimpleJSON(body)
    let text = fields["text"] ?? "Untitled"
    let note = store.add(text: text)
    responseBody = "{\"id\":\(note.id),\"text\":\"\(note.text)\"}"
    logger.info("Handled request", metadata: ["method": "POST", "path": "/notes", "id": "\(note.id)"])
default:
    status = "404 Not Found"
    responseBody = "{\"error\":\"not found\"}"
}

Every branch logs a structured line (Module 02) — an unauthorized write attempt is a warning, a successful request is info, giving an operator exactly the audit trail a real deployment needs.

Running it end to end

$ swift build
Build complete! (48.69s)

$ ./.build/debug/capstone &
2026-08-26T22:35:06+0530 info com.example.capstone: env=development port=8124 [capstone] Server started

$ curl -s -X POST http://127.0.0.1:8124/notes -d '{"text":"hello"}'
2026-08-26T22:35:06+0530 warning com.example.capstone: path=/notes [capstone] Rejected unauthorized write
{"error":"invalid or missing signature"}

$ curl -s http://127.0.0.1:8124/notes
[]

$ BODY='{"text":"hello"}'
$ KEY_HEX=$(echo -n "demo-secret-key" | shasum -a 256 | cut -d' ' -f1)
$ SIG=$(echo -n "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY_HEX | sed 's/^.* //')

$ curl -s -X POST http://127.0.0.1:8124/notes -H "X-Signature: $SIG" -d "$BODY"
2026-08-26T22:35:07+0530 info com.example.capstone: id=1 method=POST path=/notes [capstone] Handled request
{"id":1,"text":"hello"}

$ curl -s http://127.0.0.1:8124/notes
[{"id":1,"text":"hello"}]

The unsigned POST is rejected with 401 and a warning log line; once the client computes the same HMAC the server derives from API_KEY (demonstrated here with openssl dgst -mac HMAC, standing in for a real client that would use CryptoKit itself), the write succeeds and the following GET shows it persisted.

Design notes

  • The signature covers the raw body, not a parsed/re-serialized version. Signing over body exactly as received (rather than re-encoding the parsed fields) avoids a whole class of "signature valid but payload subtly different" bugs that JSON re-serialization (differing key order, whitespace) can introduce.
  • This demo derives the signing key from a passphrase for simplicity. A production system would generate and distribute a real random SymmetricKey, not derive one from a memorable string, and would rotate it periodically.
  • The HTTP/JSON layer is intentionally the same minimal, dependency-free approach as Level 3's project — see that module's design notes for why Vapor isn't used here, and its stretch goals for what a production version would add (real Codable JSON, connection keep-alive, more routes).

How It Actually Works

Pulling the whole stack together for a capstone project means several of the mechanisms from earlier chapters are now operating simultaneously, and worth being explicit about how they interact:

  • Concurrency + ARC: every Task/actor hop that crosses a class boundary triggers real retain/release traffic on captured references — in a capstone-scale app with many concurrent tasks touching shared model objects, this atomic increment/decrement cost (not raw CPU work) is often the first thing a profiler surfaces, which is why structuring shared state behind actors (serializing access, and often letting the compiler prove fewer retains are needed) tends to outperform naive "wrap everything in a class and pass it around" designs.
  • SwiftUI's view-identity diffing interacting with async data loading: a view that kicks off an async fetch in .task { } and updates @State on completion relies on the view surviving (same identity) across the suspension — if the view is torn down and recreated (e.g. due to a parent's ForEach id churn) while the fetch is in flight, the .task modifier automatically cancels the in-flight task tied to the old identity, which is a deliberate structured-concurrency safety feature, not a bug, but a common source of "why did my fetch just silently stop" confusion if you don't know to look for it.
  • End-to-end error handling across network → decode → persistence → UI layers strings together the same cheap error-register propagation from the error-handling chapter at every throws/async throws boundary, with each layer's catch translating a lower-level error type into one meaningful at its own layer — a well-architected capstone typically has 2-3 distinct error domains (network, decoding, persistence) explicitly mapped into a single app-facing error type at the boundary where the UI consumes them, rather than leaking raw URLError/DecodingError all the way up.

Stretch goals

  • Add a DELETE /notes/:id route that also requires a valid X-Signature over an empty body, and a GET /notes/:id route that doesn't (reads stay unauthenticated, consistent with the existing GET /notes).
  • Wrap NoteDatabase access in an actor (Level 3, Module 01) instead of relying on the single serial DispatchQueue, and adapt the connection-handling closures to bridge into async calls via Task { await ... } — compare the resulting code's readability to the callback-based version.
  • Containerize the finished service using the multi-stage Dockerfile pattern from Module 06, injecting API_KEY and PORT via docker run -e, and confirm the signed-request flow above still works identically against the containerized server.
  • Add a RateLimiter (reusing the design from Module 05's testing exercise) in front of the POST handler, returning 429 Too Many Requests once a client exceeds a configured request rate, and log a warning each time a request is throttled.