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:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
-80
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user