Web support for the Website service (#17)

This PR contains the work done to introduce a new Web Swift package that provides a reusable, declarative way to register route controllers on a Hummingbird router, then adopt it in the Website service.

To provide further details about the work:

* Web package
  * The `RouterController` protocol — a Sendable protocol to group controllers behind one `routes` property.
  * The `RouteCollectionBuilder`  — a result builder that collects controllers' route collections into a stack, with full support for optionals, conditionals, and arrays.
  * The `addController(_:)` method — a `RouterMethods` extension letting controllers be listed declaratively and adding each one's routes at the router root.

* Website service
  * Added Web as a dependency of the _WebsiteLibrary_ target.
  * Conformed the `RootController` and `HealthController`controllers to the `RouterController` protocol.
  * Updated the `App+Build` extension to to use the cleaner `router.addController { … }` instead.

Reviewed-on: rock-n-code/loud-amsterdam#17
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-19 00:47:49 +00:00
committed by javier
parent a4906f8f26
commit a045a23519
14 changed files with 480 additions and 105 deletions
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Web"
BuildableName = "Web"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "WebTests"
BuildableName = "WebTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Web"
BuildableName = "Web"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+47
View File
@@ -0,0 +1,47 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Web",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Web",
targets: [
"Web"
]
),
],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird.git",
from: "2.25.0"
),
],
targets: [
.target(
name: "Web",
dependencies: [
.product(
name: "Hummingbird",
package: "hummingbird"
),
],
path: "Sources"
),
.testTarget(
name: "WebTests",
dependencies: [
.byName(name: "Web"),
.product(
name: "HummingbirdTesting",
package: "hummingbird"
),
],
path: "Tests"
),
]
)
@@ -0,0 +1,46 @@
import Hummingbird
/// A result builder that collects the route collections of ``RouterController`` values into a stack.
///
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting
/// controllers be listed declaratively rather than having their routes added one statement at a time.
@resultBuilder
public enum RouteCollectionBuilder<Context: RequestContext> {
public static func buildExpression(
_ controller: some RouterController<Context>
) -> [RouteCollection<Context>] {
[controller.routes]
}
public static func buildBlock(
_ collections: [RouteCollection<Context>]...
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
public static func buildOptional(
_ collections: [RouteCollection<Context>]?
) -> [RouteCollection<Context>] {
collections ?? []
}
public static func buildEither(
first collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildEither(
second collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildArray(
_ collections: [[RouteCollection<Context>]]
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
}
@@ -0,0 +1,34 @@
import Hummingbird
public extension RouterMethods {
// MARK: Methods
/// Adds the routes of ``RouterController`` values to the router using the
/// ``RouteCollectionBuilder`` result builder.
///
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
///
/// ```swift
/// router.addController {
/// RootController<AppRequestContext>()
/// HealthController<AppRequestContext>()
/// }
/// ```
///
/// Each controller's route collection is added at the router's root, exactly as a
/// sequence of `addRoutes(_:)` calls would.
/// - Parameter build: the controller stack result builder.
/// - Returns: the router, so calls can be chained.
@discardableResult
func addController(
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
) -> Self {
for collection in build() {
addRoutes(collection)
}
return self
}
}
@@ -0,0 +1,32 @@
import Hummingbird
/// A type exposing its endpoints as a route collection ready to be added to a router.
///
/// Conforming controllers group related endpoints behind a single ``routes`` property,
/// so the application composes them declaratively with ``Hummingbird/RouterMethods/addController(_:)``:
///
/// ```swift
/// struct HealthController<Context: RequestContext>: RouterController {
/// var routes: RouteCollection<Context> {
/// RouteCollection(context: Context.self)
/// .get("health") { _, _ in HTTPResponse.Status.ok }
/// }
/// }
///
/// router.addController {
/// HealthController<AppRequestContext>()
/// }
/// ```
public protocol RouterController<Context>: Sendable {
// MARK: Associated types
/// The request context the controller's routes operate on.
associatedtype Context: RequestContext
// MARK: Properties
/// The collection of routes the controller exposes.
var routes: RouteCollection<Context> { get }
}
@@ -0,0 +1,155 @@
import Hummingbird
import HummingbirdTesting
import Testing
@testable import Web
@Suite("addController method")
struct RouterMethodsTests {
// MARK: Functional tests
@Test
func `adds the routes of every listed controller`() async throws {
let router = Router()
router.addController {
StubController(path: "first")
StubController(path: "second")
}
try await Application(router: router).test(.router) { client in
try await client.execute(
uri: "/first",
method: .get
) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "first")
}
try await client.execute(
uri: "/second",
method: .get
) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "second")
}
}
}
@Test(arguments: [true, false])
func `adds a controller behind a condition only when the condition holds`(
condition: Bool
) async throws {
let router = Router()
router.addController {
StubController(path: "always")
if condition {
StubController(path: "conditional")
}
}
try await Application(router: router).test(.router) { client in
try await client.execute(
uri: "/always",
method: .get
) { response in
#expect(response.status == .ok)
}
try await client.execute(
uri: "/conditional",
method: .get
) { response in
#expect(response.status == (condition ? .ok : .notFound))
}
}
}
@Test(arguments: [true, false])
func `adds only the taken branch of a condition`(
takesFirst: Bool
) async throws {
let router = Router()
router.addController {
if takesFirst {
StubController(path: "first")
} else {
StubController(path: "second")
}
}
try await Application(router: router).test(.router) { client in
try await client.execute(
uri: "/first",
method: .get
) { response in
#expect(response.status == (takesFirst ? .ok : .notFound))
}
try await client.execute(
uri: "/second",
method: .get
) { response in
#expect(response.status == (takesFirst ? .notFound : .ok))
}
}
}
@Test
func `adds a controller for every iteration of a loop`() async throws {
let paths = ["one", "two", "three"]
let router = Router()
router.addController {
for path in paths {
StubController(path: path)
}
}
try await Application(router: router).test(.router) { client in
for path in paths {
try await client.execute(
uri: "/\(path)",
method: .get
) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == path)
}
}
}
}
@Test
func `returns the router so calls can be chained`() async throws {
let router = Router()
router
.addController {
StubController(path: "first")
}
.addController {
StubController(path: "second")
}
try await Application(router: router).test(.router) { client in
try await client.execute(
uri: "/first",
method: .get
) { response in
#expect(response.status == .ok)
}
try await client.execute(
uri: "/second",
method: .get
) { response in
#expect(response.status == .ok)
}
}
}
}
@@ -0,0 +1,30 @@
import Hummingbird
import Web
/// A controller serving its path back as plain text, used to observe route registration.
struct StubController {
// MARK: Properties
/// The path the controller serves, also returned as the response body.
let path: String
}
// MARK: - RouterController
extension StubController: RouterController {
// MARK: Properties
var routes: RouteCollection<BasicRequestContext> {
let routes = RouteCollection(context: BasicRequestContext.self)
routes.get(.init(path)) { _, _ in
self.path
}
return routes
}
}
+6
View File
@@ -26,6 +26,9 @@ let package = Package(
.package(
path: "../../Packages/Persistence"
),
.package(
path: "../../Packages/Web"
),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -77,6 +80,7 @@ let package = Package(
dependencies: [
.byName(name: "Localization"),
.byName(name: "Persistence"),
.byName(name: "Web"),
.product(
name: "Configuration",
package: "swift-configuration"
@@ -115,6 +119,8 @@ let package = Package(
.testTarget(
name: "WebsiteLibraryTests",
dependencies: [
.byName(name: "Persistence"),
.byName(name: "Web"),
.byName(name: "WebsiteLibrary"),
.product(
name: "Elementary",
+14 -4
View File
@@ -5,6 +5,7 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Registers newsletter subscriptions at `POST /subscribe`: the landing page's form-encoded submission is validated and normalized, guarded by a hidden honeypot field against bots, and stored tagged with the request's negotiated language.
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
@@ -24,8 +25,9 @@ Two SwiftPM targets:
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
The `Website` executable depends on two local packages:
The `Website` executable depends on three local packages:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
- `Web` (`Packages/Web`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively.
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
@@ -39,6 +41,7 @@ LogRequestsMiddleware
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
SubscriptionController (POST /subscribe → newsletter subscription)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
@@ -47,8 +50,13 @@ Configuration is read through [swift-configuration](https://github.com/apple/swi
the following sources, **highest precedence first**:
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
2. Process environment variables
3. A `.env` file in the working directory (optional)
4. Built-in defaults
3. A `.env.local` file in the working directory (optional)
4. A `.env` file in the working directory (optional)
5. Built-in defaults
The two files play different roles:
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
- **`.env.local`** (tracked) holds the local development overrides — the in-memory database and `debug` logging. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
### Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName``HTTP_SERVER_NAME`,
@@ -119,6 +127,8 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
```
A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
make pkg-build # swift build
@@ -164,7 +174,7 @@ make pkg-test
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
```
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers the service's two targets `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `WebTests`, `PersistenceTests`, and `LocalizationTests`.
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
```sh
@@ -157,9 +157,11 @@ private func router(
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>(probe: probe).routes
router.addController {
RootController<AppRequestContext>()
HealthController<AppRequestContext>(
probe: probe
)
}
return router
@@ -1,13 +1,16 @@
import Hummingbird
import NIOCore
import Persistence
import Web
/// Serves the website's health-check routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
/// router.addController {
/// HealthController<AppRequestContext>(probe: probe)
/// }
/// ```
///
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
@@ -15,7 +18,7 @@ import Persistence
/// readiness failure.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct HealthController<Context: RequestContext>: Sendable {
public struct HealthController<Context: RequestContext> {
// MARK: Properties
@@ -33,13 +36,14 @@ public struct HealthController<Context: RequestContext>: Sendable {
self.probe = probe
}
// MARK: Computed
}
// MARK: - RouterController
extension HealthController: RouterController {
// MARK: Properties
/// The routes served by the controller.
///
/// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and
/// when a `Probe` was supplied a `GET` request for the readiness path (`/health/ready`)
/// that consults the probe.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -1,17 +1,20 @@
import Hummingbird
import Web
/// Serves the website's root routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
/// (or a sub-group) by the application that composes it:
/// The controller exposes its routes through its `RouterController` conformance, so the
/// application that composes it registers them declaratively:
///
/// ```swift
/// router.addRoutes(RootController<AppRequestContext>().routes)
/// router.addController {
/// RootController<AppRequestContext>()
/// }
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the
/// context of the router the routes are added to.
public struct RootController<Context: LocalizedRequestContext>: Sendable {
public struct RootController<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -25,12 +28,14 @@ public struct RootController<Context: LocalizedRequestContext>: Sendable {
self.responses = .init { IndexPage(locale: $0) }
}
// MARK: Computed
}
// MARK: - RouteController
extension RootController: RouterController {
// MARK: Properties
/// The routes served by the controller.
///
/// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the
/// language negotiated for the request.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -1,80 +0,0 @@
import Hummingbird
/// A result builder that collects ``RouteCollection`` values into a stack.
///
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route
/// collections be listed declaratively rather than added one statement at a time.
@resultBuilder
public enum RouteCollectionBuilder<Context: RequestContext> {
public static func buildExpression(
_ collection: RouteCollection<Context>
) -> [RouteCollection<Context>] {
[collection]
}
public static func buildBlock(
_ collections: [RouteCollection<Context>]...
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
public static func buildOptional(
_ collections: [RouteCollection<Context>]?
) -> [RouteCollection<Context>] {
collections ?? []
}
public static func buildEither(
first collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildEither(
second collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildArray(
_ collections: [[RouteCollection<Context>]]
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
}
// MARK: - Helpers
public extension RouterMethods {
// MARK: Methods
/// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder.
///
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
///
/// ```swift
/// router.addRoutes {
/// RootController<AppRequestContext>().routes
/// HealthController<AppRequestContext>().routes
/// }
/// ```
///
/// Each collection is added at the router's root, exactly as a sequence of
/// `addRoutes(_:)` calls would.
/// - Parameter build: the route-collection stack result builder.
/// - Returns: the router, so calls can be chained.
@discardableResult
func addRoutes(
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
) -> Self {
for collection in build() {
addRoutes(collection)
}
return self
}
}
@@ -45,6 +45,13 @@
"identifier" : "LocalizationTests",
"name" : "LocalizationTests"
}
},
{
"target" : {
"containerPath" : "container:..\/..\/Packages\/Web",
"identifier" : "WebTests",
"name" : "WebTests"
}
}
],
"version" : 1