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(
|
.package(
|
||||||
path: "../../Packages/Persistence"
|
path: "../../Packages/Persistence"
|
||||||
),
|
),
|
||||||
|
.package(
|
||||||
|
path: "../../Packages/Web"
|
||||||
|
),
|
||||||
.package(
|
.package(
|
||||||
url: "https://github.com/elementary-swift/elementary.git",
|
url: "https://github.com/elementary-swift/elementary.git",
|
||||||
from: "0.6.0"
|
from: "0.6.0"
|
||||||
@@ -77,6 +80,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.byName(name: "Localization"),
|
.byName(name: "Localization"),
|
||||||
.byName(name: "Persistence"),
|
.byName(name: "Persistence"),
|
||||||
|
.byName(name: "Web"),
|
||||||
.product(
|
.product(
|
||||||
name: "Configuration",
|
name: "Configuration",
|
||||||
package: "swift-configuration"
|
package: "swift-configuration"
|
||||||
@@ -115,6 +119,8 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "WebsiteLibraryTests",
|
name: "WebsiteLibraryTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Persistence"),
|
||||||
|
.byName(name: "Web"),
|
||||||
.byName(name: "WebsiteLibrary"),
|
.byName(name: "WebsiteLibrary"),
|
||||||
.product(
|
.product(
|
||||||
name: "Elementary",
|
name: "Elementary",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi
|
|||||||
The service:
|
The service:
|
||||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
- 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.
|
- 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).
|
- 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`.
|
- 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.
|
- 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. |
|
| `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. |
|
| `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`).
|
- `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.
|
- `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).
|
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)
|
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
|
||||||
→ FileMiddleware (serves Resources/Static)
|
→ FileMiddleware (serves Resources/Static)
|
||||||
RootController (GET / → landing page)
|
RootController (GET / → landing page)
|
||||||
|
SubscriptionController (POST /subscribe → newsletter subscription)
|
||||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
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**:
|
the following sources, **highest precedence first**:
|
||||||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||||||
2. Process environment variables
|
2. Process environment variables
|
||||||
3. A `.env` file in the working directory (optional)
|
3. A `.env.local` file in the working directory (optional)
|
||||||
4. Built-in defaults
|
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
|
### 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`,
|
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
|
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`):
|
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
||||||
```sh
|
```sh
|
||||||
make pkg-build # swift build
|
make pkg-build # swift build
|
||||||
@@ -164,7 +174,7 @@ make pkg-test
|
|||||||
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
# = 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:
|
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
|
```sh
|
||||||
|
|||||||
@@ -157,9 +157,11 @@ private func router(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.addRoutes {
|
router.addController {
|
||||||
RootController<AppRequestContext>().routes
|
RootController<AppRequestContext>()
|
||||||
HealthController<AppRequestContext>(probe: probe).routes
|
HealthController<AppRequestContext>(
|
||||||
|
probe: probe
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
import NIOCore
|
import NIOCore
|
||||||
import Persistence
|
import Persistence
|
||||||
|
import Web
|
||||||
|
|
||||||
/// Serves the website's health-check routes.
|
/// 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
|
/// ```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
|
/// 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.
|
/// 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.
|
/// - 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
|
// MARK: Properties
|
||||||
|
|
||||||
@@ -33,13 +36,14 @@ public struct HealthController<Context: RequestContext>: Sendable {
|
|||||||
self.probe = probe
|
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> {
|
public var routes: RouteCollection<Context> {
|
||||||
let routes = RouteCollection(context: Context.self)
|
let routes = RouteCollection(context: Context.self)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
import Web
|
||||||
|
|
||||||
/// Serves the website's root routes.
|
/// Serves the website's root routes.
|
||||||
///
|
///
|
||||||
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
|
/// The controller exposes its routes through its `RouterController` conformance, so the
|
||||||
/// (or a sub-group) by the application that composes it:
|
/// application that composes it registers them declaratively:
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```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
|
/// - Note: `Context` is the request context the routes are resolved against, and must match the
|
||||||
/// context of the router the routes are added to.
|
/// context of the router the routes are added to.
|
||||||
public struct RootController<Context: LocalizedRequestContext>: Sendable {
|
public struct RootController<Context: LocalizedRequestContext> {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
@@ -25,12 +28,14 @@ public struct RootController<Context: LocalizedRequestContext>: Sendable {
|
|||||||
self.responses = .init { IndexPage(locale: $0) }
|
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> {
|
public var routes: RouteCollection<Context> {
|
||||||
let routes = RouteCollection(context: Context.self)
|
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",
|
"identifier" : "LocalizationTests",
|
||||||
"name" : "LocalizationTests"
|
"name" : "LocalizationTests"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:..\/..\/Packages\/Web",
|
||||||
|
"identifier" : "WebTests",
|
||||||
|
"name" : "WebTests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 1
|
"version" : 1
|
||||||
|
|||||||
Reference in New Issue
Block a user