diff --git a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme
new file mode 100644
index 0000000..5212084
--- /dev/null
+++ b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Packages/Web/Package.swift b/Packages/Web/Package.swift
new file mode 100644
index 0000000..c0c94e8
--- /dev/null
+++ b/Packages/Web/Package.swift
@@ -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"
+ ),
+ ]
+)
diff --git a/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift
new file mode 100644
index 0000000..548bc46
--- /dev/null
+++ b/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift
@@ -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 {
+
+ public static func buildExpression(
+ _ controller: some RouterController
+ ) -> [RouteCollection] {
+ [controller.routes]
+ }
+
+ public static func buildBlock(
+ _ collections: [RouteCollection]...
+ ) -> [RouteCollection] {
+ collections.flatMap { $0 }
+ }
+
+ public static func buildOptional(
+ _ collections: [RouteCollection]?
+ ) -> [RouteCollection] {
+ collections ?? []
+ }
+
+ public static func buildEither(
+ first collections: [RouteCollection]
+ ) -> [RouteCollection] {
+ collections
+ }
+
+ public static func buildEither(
+ second collections: [RouteCollection]
+ ) -> [RouteCollection] {
+ collections
+ }
+
+ public static func buildArray(
+ _ collections: [[RouteCollection]]
+ ) -> [RouteCollection] {
+ collections.flatMap { $0 }
+ }
+
+}
diff --git a/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift
new file mode 100644
index 0000000..d753351
--- /dev/null
+++ b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift
@@ -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()
+ /// HealthController()
+ /// }
+ /// ```
+ ///
+ /// 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 _ build: () -> [RouteCollection]
+ ) -> Self {
+ for collection in build() {
+ addRoutes(collection)
+ }
+
+ return self
+ }
+
+}
diff --git a/Packages/Web/Sources/Public/Protocols/RouterController.swift b/Packages/Web/Sources/Public/Protocols/RouterController.swift
new file mode 100644
index 0000000..819ec82
--- /dev/null
+++ b/Packages/Web/Sources/Public/Protocols/RouterController.swift
@@ -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: RouterController {
+/// var routes: RouteCollection {
+/// RouteCollection(context: Context.self)
+/// .get("health") { _, _ in HTTPResponse.Status.ok }
+/// }
+/// }
+///
+/// router.addController {
+/// HealthController()
+/// }
+/// ```
+public protocol RouterController: 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 { get }
+
+}
diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift
new file mode 100644
index 0000000..1808834
--- /dev/null
+++ b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift
@@ -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)
+ }
+ }
+ }
+
+}
diff --git a/Packages/Web/Tests/Utils/Controllers/StubController.swift b/Packages/Web/Tests/Utils/Controllers/StubController.swift
new file mode 100644
index 0000000..75b7586
--- /dev/null
+++ b/Packages/Web/Tests/Utils/Controllers/StubController.swift
@@ -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 {
+ let routes = RouteCollection(context: BasicRequestContext.self)
+
+ routes.get(.init(path)) { _, _ in
+ self.path
+ }
+
+ return routes
+ }
+
+}
diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift
index fdd8c5d..39c5aad 100644
--- a/Services/Website/Package.swift
+++ b/Services/Website/Package.swift
@@ -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",
diff --git a/Services/Website/README.md b/Services/Website/README.md
index c705916..4293024 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -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
diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift
index dad5ec7..2bb0d52 100644
--- a/Services/Website/Sources/App/Extensions/App+Build.swift
+++ b/Services/Website/Sources/App/Extensions/App+Build.swift
@@ -157,9 +157,11 @@ private func router(
)
}
- router.addRoutes {
- RootController().routes
- HealthController(probe: probe).routes
+ router.addController {
+ RootController()
+ HealthController(
+ probe: probe
+ )
}
return router
diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
index ed37869..55f732e 100644
--- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
@@ -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(probe: probe).routes)
+/// router.addController {
+/// HealthController(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: Sendable {
+public struct HealthController {
// MARK: Properties
@@ -33,13 +36,14 @@ public struct HealthController: 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 {
let routes = RouteCollection(context: Context.self)
diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
index d457c73..de32221 100644
--- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
@@ -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().routes)
+/// router.addController {
+/// RootController()
+/// }
/// ```
///
/// - 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: Sendable {
+public struct RootController {
// MARK: Properties
@@ -25,12 +28,14 @@ public struct RootController: 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 {
let routes = RouteCollection(context: Context.self)
diff --git a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift b/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift
deleted file mode 100644
index 038671d..0000000
--- a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift
+++ /dev/null
@@ -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 {
-
- public static func buildExpression(
- _ collection: RouteCollection
- ) -> [RouteCollection] {
- [collection]
- }
-
- public static func buildBlock(
- _ collections: [RouteCollection]...
- ) -> [RouteCollection] {
- collections.flatMap { $0 }
- }
-
- public static func buildOptional(
- _ collections: [RouteCollection]?
- ) -> [RouteCollection] {
- collections ?? []
- }
-
- public static func buildEither(
- first collections: [RouteCollection]
- ) -> [RouteCollection] {
- collections
- }
-
- public static func buildEither(
- second collections: [RouteCollection]
- ) -> [RouteCollection] {
- collections
- }
-
- public static func buildArray(
- _ collections: [[RouteCollection]]
- ) -> [RouteCollection] {
- 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().routes
- /// HealthController().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 _ build: () -> [RouteCollection]
- ) -> Self {
- for collection in build() {
- addRoutes(collection)
- }
-
- return self
- }
-
-}
diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan
index 7b0061e..0a36976 100644
--- a/Services/Website/Tests/Website.xctestplan
+++ b/Services/Website/Tests/Website.xctestplan
@@ -45,6 +45,13 @@
"identifier" : "LocalizationTests",
"name" : "LocalizationTests"
}
+ },
+ {
+ "target" : {
+ "containerPath" : "container:..\/..\/Packages\/Web",
+ "identifier" : "WebTests",
+ "name" : "WebTests"
+ }
}
],
"version" : 1