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
@@ -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
}
}