Database setup for the Website service (#13)

This PR contains the work done to introduce a _Fluent_-based persistence layer for the Website service, selectable at runtime alongside the existing in-memory default, plus the local dev tooling and docs to support it.

To provide further details about the work:

* Persistence package
  * The `Driver` and `TLS` enumerations
  * The `Configuration` type
  * The `Service` factory that builds the  service
  * `PrepareDB` for migrations registration
  * The `Probe` for readiness checks.

* App integration
  *  Builds the driver, registers migrations, and attaches `Fluent` to the service lifecycle so it starts/stops with the HTTP server.
  * Migrate-on-boot is gated to the in-memory backend; MySQL/MariaDB is migrated out of band via --database-migrate so shared databases never race on startup.
  * The `ConfigReader+Properties` extension maps database.* config keys onto the driver.

* Library
  * Added database configuration constants.
  * The `HealthController` controller gains a readiness probe: `GET /health/ready` checks whether the database is reachable, separate from the existing liveness check.

* Others
  * Updated the `docker-compose` files to support a database service behind a database profile, and hardened for local development
  * New database targets on the `Makefile` file and overall documentation updated
  * Updated the `.env.local`, `Dockerfile`, and `README` files to document the persistence workflow, config keys, and local DB commands

Reviewed-on: rock-n-code/loud-amsterdam#13
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-11 09:15:58 +00:00
committed by javier
parent 9774454bba
commit dc6b22e648
45 changed files with 1791 additions and 254 deletions
+3
View File
@@ -51,6 +51,9 @@ playground.xcworkspace
.env
!.env.local
## Local MariaDB data files
Services/Website/Tests/DB/
# Fastlane
fastlane/report.xml
fastlane/Preview.html
-2
View File
@@ -7,8 +7,6 @@ let package = Package(
defaultLocalization: "en",
platforms: [
.macOS(.v15),
.iOS(.v18),
.tvOS(.v18),
],
products: [
.library(
@@ -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 = "Persistence"
BuildableName = "Persistence"
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 = "PersistenceTests"
BuildableName = "PersistenceTests"
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 = "Persistence"
BuildableName = "Persistence"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+67
View File
@@ -0,0 +1,67 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Persistence",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Persistence",
targets: [
"Persistence"
]
)
],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird-fluent.git",
from: "2.0.0"
),
.package(
url: "https://github.com/vapor/fluent-mysql-driver.git",
from: "4.8.0"
),
.package(
url: "https://github.com/vapor/fluent-sqlite-driver.git",
from: "4.9.0"
),
.package(
url: "https://github.com/vapor/sql-kit.git",
from: "3.36.0"
),
],
targets: [
.target(
name: "Persistence",
dependencies: [
.product(
name: "HummingbirdFluent",
package: "hummingbird-fluent"
),
.product(
name: "FluentMySQLDriver",
package: "fluent-mysql-driver"
),
.product(
name: "FluentSQLiteDriver",
package: "fluent-sqlite-driver"
),
.product(
name: "SQLKit",
package: "sql-kit"
),
],
path: "Sources"
),
.testTarget(
name: "PersistenceTests",
dependencies: [
.byName(name: "Persistence")
],
path: "Tests"
),
]
)
@@ -0,0 +1,28 @@
import FluentKit
/// Creates and drops the `example_records` table backing ``ExampleRecord``.
///
/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a
/// domain model is defined. Migrations are append-only in production add a new migration to alter the
/// schema rather than editing one that has already run.
struct CreateExampleRecord: AsyncMigration {
// MARK: Methods
/// Creates the `example_records` table with an `id` primary key and a required `name` column.
/// - Parameter database: the database the schema change is applied to.
func prepare(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.id()
.field("name", .string, .required)
.create()
}
/// Drops the `example_records` table, reverting ``prepare(on:)``.
/// - Parameter database: the database the schema change is applied to.
func revert(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.delete()
}
}
@@ -0,0 +1,45 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `example_records` row.
///
/// This is reference scaffolding: it demonstrates the model migration repository pattern the rest of
/// the package is built around, and is what the tests exercise. Replace it with the first real domain model
/// (paired with its own migration and repository) once one is defined.
///
/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never
/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the
/// conformance is declared `@unchecked Sendable`.
final class ExampleRecord: Model, @unchecked Sendable {
// MARK: Properties
/// The name of the backing table.
static let schema = "example_records"
/// The row's primary key, assigned on first save.
@ID(key: .id)
var id: UUID?
/// The row's name column.
@Field(key: "name")
var name: String
// MARK: Initializers
/// Creates an empty record, as required by FluentKit to hydrate query results.
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - id: the primary key, or `nil` to have one assigned on save.
/// - name: the value of the name column.
init(
id: UUID? = nil,
name: String
) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,63 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries.
///
/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference
/// types that must not escape the database's execution context.
public struct Example: Sendable, Equatable {
// MARK: Properties
/// The record's primary key, or `nil` if it has never been saved.
public let id: UUID?
/// The record's name.
public let name: String
}
/// Reads and writes ``ExampleRecord`` rows through the default database.
///
/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the
/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning so no
/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``;
/// replace it with the first real repository once a domain model is defined.
public struct ExampleRepository: Sendable {
// MARK: Properties
/// The service providing the default database the repository reads and writes through.
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(fluent: Fluent) {
self.fluent = fluent
}
// MARK: Methods
/// Inserts a record with the given name.
/// - Parameter name: the name of the record to insert.
/// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier.
public func create(name: String) async throws -> Example {
let record = ExampleRecord(name: name)
try await record.save(on: fluent.db())
return Example(id: record.id, name: record.name)
}
/// Fetches every record, ordered by name.
/// - Returns: a `Sendable` snapshot of each record, sorted by name.
public func all() async throws -> [Example] {
try await ExampleRecord.query(on: fluent.db())
.sort(\.$name)
.all()
.map { Example(id: $0.id, name: $0.name) }
}
}
@@ -0,0 +1,20 @@
/// The persistence backend the service runs against.
///
/// The executable picks a driver at startup and hands it to ``Service``, which registers the
/// matching database as the default one. Repositories resolve that default and stay agnostic
/// of which backend is in use.
public enum Driver: Sendable {
/// A MySQL/MariaDB server, reached with the given connection parameters.
///
/// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the
/// connection is opened with.
case mysql(Configuration)
/// An ephemeral, in-process SQLite database held entirely in memory.
///
/// Nothing is written to disk, and all data is lost when the service stops intended for
/// local development and tests.
case inMemory
}
@@ -0,0 +1,42 @@
import NIOSSL
/// The TLS posture used when connecting to the database.
///
/// The executable derives a posture from its `database.tls` configuration and passes it along as
/// part of ``Configuration``; the MySQL driver receives the resulting `TLSConfiguration` through
/// ``tlsConfiguration``.
public enum TLS: Sendable {
/// Connect without TLS, in plaintext.
case off
/// Connect over TLS when the server offers it, falling back to plaintext otherwise.
case prefer
/// Connect only over TLS, refusing the connection when the server offers none.
case require
}
// MARK: - Properties
extension TLS {
/// The NIO TLS configuration passed to the MySQL driver for this posture.
///
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for
/// ``prefer`` and ``require``.
///
/// - Note: `prefer` and `require` currently map to the same client configuration both enable TLS.
/// The distinction (fall back to plaintext vs. fail when the server offers no TLS) is not yet
/// enforced here; tighten this mapping if that guarantee becomes required.
var tlsConfiguration: TLSConfiguration? {
switch self {
case .off:
return nil
case .prefer, .require:
return .makeClientConfiguration()
}
}
}
@@ -0,0 +1,29 @@
import HummingbirdFluent
/// A registrar declaring every migration against a `Fluent` service.
///
/// Built once around the application's `Fluent` service and called as a function `await migrate()`
/// during startup, before the migrations are applied.
public struct PrepareDB {
// MARK: Initializers
/// Creates a registrar for the migrations for a `Fluent` service.
public init() {}
// MARK: Methods
/// Registers every migration against the `Fluent` service, in order.
///
/// This is the single place migrations are declared: add each new migration here, in the order it must
/// run (migrations are applied in registration order and are append-only). Registering does not apply
/// them the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do that.
public func callAsFunction(
for fluent: Fluent
) async {
await fluent.migrations.add([
CreateExampleRecord()
])
}
}
@@ -0,0 +1,49 @@
import HummingbirdFluent
import SQLKit
/// A readiness probe reporting whether the database behind a `Fluent` service is reachable.
///
/// Built once around the application's `Fluent` service and called as a function whenever a fresh
/// answer is needed typically from a readiness endpoint: `let ready = await probe()`.
public struct Probe: Sendable {
// MARK: Properties
/// The `Fluent` service whose default database is probed.
private let fluent: Fluent
// MARK: Initializers
/// Creates a probe for the default database of the given `Fluent` service.
/// - Parameter fluent: the `Fluent` service whose default database is probed.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Reports whether the database behind the `Fluent` service is reachable.
///
/// Runs a trivial `SELECT 1` against the default database the cheapest statement both the MySQL/MariaDB
/// and SQLite backends understand so a readiness check does not depend on any particular schema or model.
/// Any failure (connection refused, authentication error, pool exhausted) is reported as not reachable
/// rather than thrown, so callers can map it straight onto a readiness response. A default database that
/// is not an SQL database is likewise reported as not reachable.
/// - Returns: `true` when the database answers the probe, `false` otherwise.
public func callAsFunction() async -> Bool {
guard let database = fluent.db() as? any SQLDatabase else {
return false
}
do {
try await database.raw("SELECT 1").run()
return true
} catch {
return false
}
}
}
@@ -0,0 +1,78 @@
import FluentMySQLDriver
import FluentSQLiteDriver
import HummingbirdFluent
import Logging
/// A factory building the `Fluent` service the application persists through.
///
/// Built once around the driver the executable picks at startup and called as a function to produce
/// the configured service: `let fluent = service()`.
public struct Service: Sendable {
// MARK: Properties
/// The persistence backend to register.
private let driver: Driver
/// The logger the database emits through.
private let logger: Logger
// MARK: Initializers
/// Creates a factory for a `Fluent` service backed by the given driver.
/// - Parameters:
/// - driver: the persistence backend to register.
/// - logger: the logger the database emits through.
public init(
driver: Driver,
logger: Logger
) {
self.driver = driver
self.logger = logger
}
// MARK: Methods
/// Builds a `Fluent` service configured for the driver.
///
/// The selected backend is registered as the *default* database, so repositories resolve it with a plain
/// `fluent.db()` and stay agnostic of which driver is in use. The returned service is not yet running; add
/// it to the application's service group (`app.addServices(_:)`) so it starts and shuts its connection pool
/// down alongside the server.
/// - Returns: the configured `Fluent` service, ready to be added to the service group.
public func callAsFunction() -> Fluent {
let fluent = Fluent(
logger: logger
)
switch driver {
case .mysql(let configuration):
fluent.databases.use(
.mysql(
configuration: .init(
hostname: configuration.host,
port: configuration.port,
username: configuration.username,
password: configuration.password,
database: configuration.name,
tlsConfiguration: configuration.tls.tlsConfiguration
),
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop
),
as: .mysql,
isDefault: true
)
case .inMemory:
// A single connection keeps every query pointed at the same in-memory store,
// rather than each pooled connection getting its own private database.
fluent.databases.use(
.sqlite(.memory, maxConnectionsPerEventLoop: 1),
as: .sqlite,
isDefault: true
)
}
return fluent
}
}
@@ -0,0 +1,60 @@
/// The connection parameters for the MySQL/MariaDB backend.
///
/// The executable builds this from its `database.*` configuration; the package itself reads no
/// configuration, so these values arrive as plain data.
public struct Configuration: Sendable {
// MARK: Properties
/// The host the database server is reached at.
let host: String
/// The maximum number of pooled connections opened per event loop.
let maxConnectionsPerEventLoop: Int
/// The name of the database to open.
let name: String
/// The password the connection authenticates with.
let password: String
/// The port the database server listens on.
let port: Int
/// The TLS posture used when connecting.
let tls: TLS
/// The username the connection authenticates as.
let username: String
// MARK: Initializers
/// Creates a set of MySQL/MariaDB connection parameters.
/// - Parameters:
/// - host: the host the database server is reached at.
/// - port: the port the database server listens on.
/// - name: the name of the database to open.
/// - username: the username the connection authenticates as.
/// - password: the password the connection authenticates with.
/// - tls: the TLS posture used when connecting.
/// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop.
public init(
host: String,
port: Int,
name: String,
username: String,
password: String,
tls: TLS,
maxConnectionsPerEventLoop: Int
) {
self.host = host
self.port = port
self.name = name
self.username = username
self.password = password
self.tls = tls
self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop
}
}
@@ -0,0 +1,28 @@
import NIOSSL
import Testing
@testable import Persistence
@Suite("TLS enumeration")
struct TLSTests {
// MARK: Properties tests
@Test
func `off has no TLS configuration`() {
#expect(TLS.off.tlsConfiguration == nil)
}
@Test(arguments: [
TLS.prefer,
TLS.require
])
func `maps to the default client configuration`(
for tls: TLS
) throws {
let configuration = try #require(tls.tlsConfiguration)
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
}
}
@@ -0,0 +1,77 @@
import FluentKit
import HummingbirdFluent
import Logging
import NIOCore
import Testing
@testable import Persistence
@Suite("Probe method")
struct ProbeTests {
// MARK: Methods tests
@Test
func `reports a reachable database`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(isReachable)
}
@Test
func `reports an unreachable database`() async throws {
// Port 1 on the loopback interface has nothing listening, so the connection is refused
// immediately instead of timing out.
let service = Service(
driver: .mysql(
.init(
host: "127.0.0.1",
port: 1,
name: "unreachable",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
@Test
func `reports a default database that is not an SQL database`() async throws {
let fluent = Fluent(logger: Logger(label: "test"))
fluent.databases.use(
.init(make: { NotSQLConfiguration() }),
as: .init(string: "not-sql"),
isDefault: true
)
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
}
@@ -0,0 +1,165 @@
import Foundation
import Logging
import SQLKit
import Testing
@testable import Persistence
@Suite("Service method")
struct ServiceTests {
// MARK: Methods tests
@Test
func `registers an SQLite database as the default for the in-memory driver`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "sqlite")
}
@Test
func `registers a MySQL database as the default for the mysql driver`() async throws {
// Resolving the default database opens no connection pooling is lazy so no server
// needs to be listening on the configured host and port.
let service = Service(
driver: .mysql(
.init(
host: "127.0.0.1",
port: 3306,
name: "loud",
username: "loud",
password: "loud",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "mysql")
}
@Test
func `builds a usable in-memory database`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
do {
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test("in-memory: migrate, insert, read back")
func inMemoryRoundTrip() async throws {
try await roundTrip(driver: .inMemory)
}
@Test(
"mysql: migrate, insert, read back",
.enabled(if: mysqlDriver != nil)
)
func mysqlRoundTrip() async throws {
try await roundTrip(
driver: mysqlDriver!,
revertAfter: true
)
}
}
// MARK: - Helpers
private extension ServiceTests {
/// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after.
///
/// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that,
/// the test must shut it down explicitly even on failure or the pool asserts on `deinit`.
/// - Parameters:
/// - driver: the persistence backend to exercise.
/// - revertAfter: whether to revert the migrations afterwards; set for a shared database (the
/// in-memory database is discarded on shutdown, so it needs no revert).
func roundTrip(
driver: Persistence.Driver,
revertAfter: Bool = false
) async throws {
let service = Service(
driver: driver,
logger: Logger(label: "test")
)
let fluent = service()
do {
await registerMigrations(fluent)
try await fluent.migrate()
let repository = ExampleRepository(fluent: fluent)
let created = try await repository.create(name: "loud")
#expect(try await repository.all().contains(created))
if revertAfter {
try await fluent.revert()
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
/// The MySQL/MariaDB driver built from the `MYSQL_TEST_*` environment variables, or `nil` when the gate
/// variable `MYSQL_TEST_HOST` is unset in which case the MySQL integration test is skipped, so the suite
/// stays runnable with no database available.
private let mysqlDriver: Persistence.Driver? = {
let environment = ProcessInfo.processInfo.environment
guard let host = environment["MYSQL_TEST_HOST"] else {
return nil
}
return .mysql(
.init(
host: host,
port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306,
name: environment["MYSQL_TEST_NAME"] ?? "loud",
username: environment["MYSQL_TEST_USERNAME"] ?? "loud",
password: environment["MYSQL_TEST_PASSWORD"] ?? "loud",
tls: .off,
maxConnectionsPerEventLoop: 2
)
)
}()
@@ -0,0 +1,13 @@
import FluentKit
struct NotSQLConfiguration: DatabaseConfiguration {
var middleware: [any AnyModelMiddleware] = []
func makeDriver(
for databases: Databases
) -> any DatabaseDriver {
NotSQLDriver()
}
}
@@ -0,0 +1,44 @@
import FluentKit
/// A Fluent database that is not an `SQLDatabase`, so the probe's downcast fails.
///
/// Every query succeeds, proving the probe reports "not reachable" because of the failed downcast
/// rather than a failing backend.
struct NotSQLDatabase: Database {
let context: DatabaseContext
var inTransaction: Bool { false }
func execute(
query: DatabaseQuery,
onOutput: @escaping @Sendable (any DatabaseOutput) -> Void
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
schema: DatabaseSchema
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
enum: DatabaseEnum
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func transaction<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
func withConnection<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
}
@@ -0,0 +1,13 @@
import FluentKit
struct NotSQLDriver: DatabaseDriver {
func makeDatabase(
with context: DatabaseContext
) -> any Database {
NotSQLDatabase(context: context)
}
func shutdown() {}
}
+25
View File
@@ -40,3 +40,28 @@ HTTP_SERVER_NAME=LoudWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
LOG_LEVEL=info
# --- Persistence ----------------------------------------------------------------
# Persistence driver: inMemory (default, no infrastructure) or mysql.
DATABASE_DRIVER=inMemory
# MySQL/MariaDB connection, used when DATABASE_DRIVER=mysql.
# `mariadb` is the local database's Compose service name; use 127.0.0.1 when
# running the app directly with `swift run`.
DATABASE_HOST=localhost
# Port of the database to connect to.
DATABASE_PORT=3306
# Name of the database to connect to.
DATABASE_NAME=loud-ams
# Username of the database to connect as.
DATABASE_USERNAME=loud-ams
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=loud-ams
# TLS posture when connecting: off | prefer | require (use `require` in production).
DATABASE_TLS=off
+1
View File
@@ -18,6 +18,7 @@ WORKDIR /build
# not change. The Website package depends on the local Localization package via
# a relative path, so its manifest must be present for resolution to succeed.
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
RUN swift package --package-path ./Services/Website resolve
+60 -12
View File
@@ -38,36 +38,84 @@ pkg-clean: ## Remove the Swift build artifacts
@swift package clean
.PHONY: pkg-reset
pkg-reset: ## Resets the complete SPM cache/build folder
pkg-reset: ## Reset the SPM cache and build folder
@swift package reset
.PHONY: pkg-outdated
pkg-outdated: ## Lists the SPM package dependencies that can be updated
pkg-outdated: ## List the SPM dependencies that can be updated
@swift package update --dry-run
.PHONY: pkg-update
pkg-update: ## Updates the SPM package dependencies
pkg-update: ## Update the SPM dependencies
@swift package update
# --- Local development --------------------------------------------------------
.PHONY: img-build
img-build: ## Build the local dev image
@docker compose build
.PHONY: site-run
site-run: ## Run the website locally, rebuilding on source changes
@hb watch
.PHONY: img-mount
img-mount: ## Mount the service locally (build if needed)
@docker compose up --build --detach
.PHONY: site-mount
site-mount: ## Mount the website locally
@docker compose up \
--build \
--detach
.PHONY: img-unmount
img-unmount: ## Unmount and remove the local service
.PHONY: site-unmount
site-unmount: ## Unmount and remove the local website
@docker compose down
@$(MAKE) img-remove
# --- Local database -----------------------------------------------------------
.PHONY: db-mount
db-mount: ## Start the local database instance
@docker compose \
--profile database up \
--detach \
--wait mariadb
.PHONY: db-migrate
db-migrate: ## Run the migrations against the local database instance
@DATABASE_DRIVER=mysql \
DATABASE_HOST=127.0.0.1 \
DATABASE_TLS=off \
swift run \
Website \
--database-migrate
.PHONY: db-shell
db-shell: ## Open a SQL shell on the local database instance
@docker compose \
--profile database \
exec mariadb \
mariadb \
--user=$(or $(DATABASE_USERNAME),loud) \
--password=$(or $(DATABASE_PASSWORD),loud) \
$(or $(DATABASE_NAME),loud)
.PHONY: db-unmount
db-unmount: ## Stop and remove the local database instance (keeps the data volume)
@docker compose \
--profile database down mariadb
.PHONY: db-reset
db-reset: ## Stop and remove the local database instance and delete its data volume
@docker compose \
--profile database down mariadb \
--volumes
# --- Registry deployment ------------------------------------------------------
.PHONY: img-check
img-check: ## Check the production image builds
@docker build \
--platform linux/amd64 \
--file Dockerfile \
../..
.PHONY: img-release
img-release: ## Build the production (amd64) image, tag with version + latest, push both
img-release: ## Build and push the production image into the container registry
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
exit 1; \
+5
View File
@@ -23,6 +23,9 @@ let package = Package(
.package(
path: "../../Packages/Localization"
),
.package(
path: "../../Packages/Persistence"
),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -52,6 +55,7 @@ let package = Package(
.executableTarget(
name: "Website",
dependencies: [
.byName(name: "Persistence"),
.byName(name: "WebsiteCore"),
.product(
name: "Configuration",
@@ -72,6 +76,7 @@ let package = Package(
name: "WebsiteCore",
dependencies: [
.byName(name: "Localization"),
.byName(name: "Persistence"),
.product(
name: "Configuration",
package: "swift-configuration"
+75 -6
View File
@@ -5,24 +5,30 @@ 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 `WebsiteCore` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Answers health checks at `GET /health` with a static JSON payload.
- 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.
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Docker (optional) for the containerized run/deploy workflow.
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
## Architecture
Two SwiftPM targets:
| Target | Kind | Path | Role |
| --- | --- | --- | --- |
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. |
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
`WebsiteCore` also depends on the local `Localization` package (`Packages/Localization`), which provides the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages.
The `Website` executable depends on two local packages:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteCore`).
- `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).
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
```
@@ -33,7 +39,7 @@ LogRequestsMiddleware
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
HealthController (GET /health → health check)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
## Configuration
@@ -74,6 +80,21 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
| --- | --- | --- | --- |
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
### Persistence
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
| `database.host` | `DATABASE_HOST` | `localhost` | MySQL/MariaDB host. Ignored for `inMemory`. |
| `database.port` | `DATABASE_PORT` | `3306` | MySQL/MariaDB port. Ignored for `inMemory`. |
| `database.name` | `DATABASE_NAME` | `loud` | Database name. Ignored for `inMemory`. |
| `database.username` | `DATABASE_USERNAME` | `loud` | Database username. Ignored for `inMemory`. |
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
See [Persistence](#persistence-1) below for the workflow.
### Paths
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
@@ -101,12 +122,42 @@ swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
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
make img-mount # docker compose up --build --detach
make img-unmount # docker compose down + remove the local image
make site-run # run locally with hot reload (hb watch)
make site-mount # docker compose up --build --detach
make site-unmount # docker compose down + remove the local image
```
`make help` lists every available target.
## Persistence
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
### In-memory (default)
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
### MySQL / MariaDB
Set `DATABASE_DRIVER=mysql` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, a MySQL/MariaDB database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
```sh
# Run the registered migrations against the configured database, then exit.
swift run Website --database-migrate
# In a container (production), against the managed database:
docker compose -f docker-compose.yml run --rm website --database-migrate
```
A local MariaDB for development lives behind the `database` Compose profile (so a plain `docker compose up` still runs in-memory). Its data directory is bind-mounted to `Tests/DB` (git-ignored), so the database survives `db-unmount` and container restarts:
```sh
make db-mount # start MariaDB (docker compose --profile database up --wait mariadb)
make db-migrate # run migrations against it
make db-shell # open a SQL shell on it
make db-unmount # stop and remove the container (data kept in Tests/DB)
make db-reset # stop and remove the container
DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
```
> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
### Health checks
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
## Testing
```sh
make pkg-test
@@ -115,8 +166,21 @@ make pkg-test
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteCoreTests` (the library unit tests).
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
cd ../../Packages/Persistence && swift test # in-memory only
# With the local MariaDB up (make db-mount):
cd ../../Packages/Persistence && MYSQL_TEST_HOST=127.0.0.1 swift test
```
## Deployment
The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
Verify the image builds for its `linux/amd64` target without tagging or publishing:
```sh
make img-check
```
Build, tag, and push a release to the registry (an explicit version is required):
```sh
make img-release version=1.2.3
@@ -141,3 +205,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
| `LOG_LEVEL` | Runtime log level (default `info`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). |
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
@@ -1,203 +0,0 @@
import Configuration
import Hummingbird
import HummingbirdCompression
import Logging
import WebsiteCore
/// Builds the website application.
///
/// Reads the log level, server name, static files location, minimum response size to compress, and
/// security headers from the configuration, then assembles the router, server configuration, and logger.
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
let cacheControl = cacheControl(
textMaxAge: reader.int(
forKey: .Cache.maxAgeText,
default: .Cache.maxAgeText
),
imageMaxAge: reader.int(
forKey: .Cache.maxAgeImage,
default: .Cache.maxAgeImage
),
defaultMaxAge: reader.int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
)
)
let compressionMinResponseSize = reader.int(
forKey: .Compression.minResponseSize,
default: .Compression.minResponseSize
)
let logLevel = reader.string(
forKey: .Log.level,
as: Logger.Level.self,
default: .info
)
let serverName = reader.string(
forKey: .HTTP.serverName,
default: .Server.name
)
let staticFilesPath = reader.string(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
let securityHeaders = securityHeaders(
reader: reader
)
return Application(
router: router(
staticFilesPath: staticFilesPath,
cacheControl: cacheControl,
compressionMinResponseSize: compressionMinResponseSize,
securityHeaders: securityHeaders,
logLevel: logLevel
),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
),
logger: logger(
serverName: serverName,
logLevel: logLevel
)
)
}
// MARK: - Helpers
// Request context used by application
private typealias AppRequestContext = WebsiteRequestContext
/// Builds the cache-control policy applied to the served static files.
///
/// Static files are public and validated by `FileMiddleware` through their `ETag` and
/// `Last-Modified` headers, so each media type is given a `max-age` after which the browser
/// revalidates. Text-based assets (CSS, JavaScript) are additionally marked `must-revalidate`
/// since they change between deployments while keeping their filenames.
/// - Parameters:
/// - textMaxAge: the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
/// - imageMaxAge: the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
/// - defaultMaxAge: the max-age, in seconds, applied to all other static files (e.g. the web manifest).
/// - Returns: the configured cache-control policy.
private func cacheControl(
textMaxAge: Int,
imageMaxAge: Int,
defaultMaxAge: Int
) -> CacheControl {
.init([
(.text, [.public, .maxAge(textMaxAge), .mustRevalidate]),
(.image, [.public, .maxAge(imageMaxAge)]),
(.init(type: .any), [.public, .maxAge(defaultMaxAge)]),
])
}
/// Builds the security-headers configuration applied to every response.
///
/// Each header value falls back to the hardened default in `String.Security` when the matching
/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional
/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and
/// is enabled only behind TLS in production.
/// - Parameter reader: the configuration reader the header values are read from.
/// - Returns: the configured security-headers configuration.
private func securityHeaders(
reader: ConfigReader
) -> SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: reader.string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: reader.string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: reader.string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: reader.string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: reader.string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: reader.string(
forKey: .Security.strictTransportSecurity
)
)
}
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
/// - logLevel: the minimum level the logger emits.
/// - Returns: the configured logger.
private func logger(
serverName: String,
logLevel: Logger.Level
) -> Logger {
var logger = Logger(label: serverName)
logger.logLevel = logLevel
return logger
}
/// Builds the application's router.
///
/// Registers the request-logging middleware, the security-headers middleware that stamps the given
/// `securityHeaders` onto every response, the response-compression middleware that compresses
/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the
/// localization middleware that negotiates the request's language from its `Accept-Language` header,
/// the not-found middleware that serves the error page, and the static file middleware that serves the
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then
/// adds the `RootController` routes that render the landing page and the `HealthController` routes
/// that serve the health check.
///
/// The security-headers middleware sits just inside request logging so it covers every response that
/// reaches a client the landing page, the compressed responses, the rendered error page, and the
/// served static files.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware()
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>().routes
}
return router
}
+21
View File
@@ -2,8 +2,17 @@ import Configuration
import Hummingbird
import Logging
/// The entry point of the website executable.
///
/// Loads the configuration, then either serves the website or when the `database.migrate` flag is set runs the registered migrations against the
/// configured backend and exits.
@main
struct App {
/// Loads the configuration and runs the mode it selects.
///
/// The configuration is read from the providers in precedence order: command-line arguments first, then process environment variables, then a
/// `.env` file when one is present, and finally the in-memory defaults (currently just the server name).
static func main() async throws {
let reader = try await ConfigReader(
providers: [
@@ -19,10 +28,22 @@ struct App {
]
)
// Migrate-and-exit mode runs the registered migrations against the configured backend and returns,
// so a shared database is migrated by a single deliberate invocation (`--database-migrate`) rather
// than by every booting instance.
guard !reader.migrate else {
try await migration(
reader: reader
)
return
}
let app = await application(
reader: reader
)
try await app.runService()
}
}
@@ -0,0 +1,166 @@
import Configuration
import Hummingbird
import HummingbirdCompression
import Logging
import Persistence
import WebsiteCore
/// Builds the website application.
///
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
/// the router, server configuration, and logger. It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of
/// band (so a shared database is never migrated on boot).
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
let logger = logger(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let persistence = Service(
driver: reader.driver,
logger: logger
)
let fluent = persistence()
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
securityHeaders: reader.securityHeaders,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
),
logger: logger
)
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB
// backend is left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = reader.driver {
app.beforeServerStarts {
try await fluent.migrate()
}
}
return app
}
/// Runs every registered migration against the configured backend, then exits.
///
/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
/// migrations, and shuts the database down so a shared MySQL/MariaDB database is migrated by a single deliberate invocation rather than by every
/// booting instance.
/// - Parameter reader: the configuration reader the values are read from.
func migration(
reader: ConfigReader
) async throws {
let logger = logger(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let service = Service(
driver: reader.driver,
logger: logger
)
let fluent = service()
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
do {
try await fluent.migrate()
}
catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
// MARK: - Helpers
/// The request context type the application serves its routes with.
private typealias AppRequestContext = WebsiteRequestContext
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
/// - logLevel: the minimum level the logger emits.
/// - Returns: the configured logger.
private func logger(
serverName: String,
logLevel: Logger.Level
) -> Logger {
var logger = Logger(label: serverName)
logger.logLevel = logLevel
return logger
}
/// Builds the application's router.
///
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
/// response-compression middleware that compresses responses larger than `minimumResponseSizeToCompress` when the client advertises support,
/// the localization middleware that negotiates the request's language from its `Accept-Language` header, the not-found middleware that serves the
/// error page, and the static file middleware that serves the contents of `staticFilesPath` (tagging responses with the given `cacheControl`
/// directives), then adds the `RootController` routes that render the landing page and the `HealthController` routes that serve the health check.
///
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client the landing page, the compressed
/// responses, the rendered error page, and the served static files.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware()
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>(probe: probe).routes
}
return router
}
@@ -0,0 +1,182 @@
import Configuration
import Hummingbird
import Logging
import Persistence
import WebsiteCore
package extension ConfigReader {
// MARK: Type aliases
/// The request context type the application serves its routes with; the security headers configuration is generic over it.
typealias AppRequestContext = WebsiteRequestContext
// MARK: Computed
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS,
/// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
var cacheControl: CacheControl {
let maxAgeDefault = int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
)
let maxAgeImage = int(
forKey: .Cache.maxAgeImage,
default: .Cache.maxAgeImage
)
let maxAgeText = int(
forKey: .Cache.maxAgeText,
default: .Cache.maxAgeText
)
return .init([
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
(.image, [.public, .maxAge(maxAgeImage)]),
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
])
}
/// The minimum response body size, in bytes, before a response is compressed read from the `compression.minimumResponseSize` key.
var compressionMinResponseSize: Int {
int(
forKey: .Compression.minResponseSize,
default: .Compression.minResponseSize
)
}
/// The persistence backend the service runs against, derived from the `database.*` keys.
///
/// When `database.driver` selects MySQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, and
/// `database.pool.maxPerEventLoop` keys. Any other driver value falls back to the in-memory database.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverMySQL:
return .mysql(
.init(
host: string(
forKey: .Database.host,
default: .Database.host
),
port: int(
forKey: .Database.port,
default: .Database.port
),
name: string(
forKey: .Database.name,
default: .Database.name
),
username: string(
forKey: .Database.username,
default: .Database.username
),
password: string(
forKey: .Database.password,
default: ""
),
tls: tls,
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
)
)
)
default:
return .inMemory
}
}
/// The minimum log level the application emits at, read from the `log.level` key.
///
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
var logLevel: Logger.Level {
string(
forKey: .Log.level,
as: Logger.Level.self,
default: .info
)
}
/// Whether the executable runs in migrate-and-exit mode instead of serving, read from the `database.migrate` flag; off by default.
var migrate: Bool {
bool(
forKey: .Database.migrate,
default: false
)
}
/// The security headers middleware configuration, built from the `security.*` keys.
///
/// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
/// is set the header is a commitment browsers cache, so it must be opted into for deployments actually served over HTTPS.
var securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: string(
forKey: .Security.strictTransportSecurity
)
)
}
/// The name the server reports in its `Server` response header, read from the `http.serverName` key.
var serverName: String {
string(
forKey: .HTTP.serverName,
default: .Server.name
)
}
/// The directory the static files are served from, read from the `path.staticFiles` key.
var staticFilesPath: String {
string(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
}
}
// MARK: - Helpers
private extension ConfigReader {
// MARK: Properties
/// The TLS posture for the MySQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// other value falls back to `prefer`.
var tls: TLS {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsRequire: .require
default: .prefer
}
}
}
@@ -1,36 +1,45 @@
import Hummingbird
import NIOCore
import Persistence
/// Serves the website's health-check route.
/// 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 as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
///
/// ```swift
/// router.addRoutes(HealthController<AppRequestContext>().routes)
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the
/// context of the router the routes are added to.
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
/// whether the service's database is reachable. The two are kept distinct so an orchestrator can restart on liveness failure but only withhold traffic on
/// 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 {
// MARK: Properties
/// The JSON payload returned for every health check.
private let payload: String
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
private let probe: Probe?
// MARK: Initializers
/// Creates a health controller.
public init() {
self.payload = #"{"status":"ok"}"#
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
/// route is served.
public init(
probe: Probe? = nil
) {
self.probe = probe
}
// MARK: Computed
/// The routes served by the controller.
///
/// Serves a `GET` request for the health path (`/health`) with a static JSON status payload.
/// 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)
@@ -39,6 +48,13 @@ public struct HealthController<Context: RequestContext>: Sendable {
use: check
)
if probe != nil {
routes.get(
.Health.ready,
use: ready
)
}
return routes
}
@@ -50,10 +66,12 @@ private extension HealthController {
// MARK: Methods
/// Handles a request for the health check.
/// Handles a request for the liveness check.
///
/// Returns a constant JSON body built directly per request the payload is a tiny literal with no
/// rendering step, so there is nothing to pre-render or cache.
/// rendering step, so there is nothing to pre-render or cache. It reports only that the process is up,
/// with no dependency check, so an orchestrator restarts the process only when the process itself is
/// unresponsive.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
@@ -63,8 +81,50 @@ private extension HealthController {
request: Request,
context: some RequestContext
) -> Response {
Response(
json(
status: .ok,
payload: .Payload.live
)
}
/// Handles a request for the readiness check.
///
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's
/// database is reachable, or `503 Service Unavailable` otherwise, so a load balancer withholds
/// traffic from an instance that cannot yet serve it without restarting the process.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: a `200 OK` response when ready, or `503 Service Unavailable` when not.
@Sendable
func ready(
request: Request,
context: some RequestContext
) async -> Response {
guard await probe?() == true else {
return json(
status: .serviceUnavailable,
payload: .Payload.unavailable
)
}
return json(
status: .ok,
payload: .Payload.ready
)
}
/// Builds a JSON response carrying the given status and payload.
/// - Parameters:
/// - status: the HTTP status of the response.
/// - payload: the JSON body of the response.
/// - Returns: the configured JSON response.
func json(
status: HTTPResponse.Status,
payload: String
) -> Response {
Response(
status: status,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: .init(string: payload))
)
@@ -72,12 +132,24 @@ private extension HealthController {
}
// MARK: - Constants
// MARK: - RouterPath+Constants
private extension RouterPath {
/// A namespace for the ``HealthController`` route paths.
enum Health {
/// The path of the health-check endpoint.
/// The path of the liveness endpoint.
static let check: RouterPath = "/health"
/// The path of the readiness endpoint.
static let ready: RouterPath = "/health/ready"
}
}
// MARK: - String+Constants
private extension String {
enum Payload {
static let live = #"{"status":"ok"}"#
static let ready = #"{"status":"ready"}"#
static let unavailable = #"{"status":"unavailable"}"#
}
}
@@ -15,6 +15,27 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
}
/// A namespace for the persistence configuration keys, as absolute keys.
public enum Database {
/// The absolute configuration key selecting migrate-and-exit mode.
public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
/// The absolute configuration key for the persistence driver.
public static let driver: AbsoluteConfigKey = .init(.Database.driver)
/// The absolute configuration key for the MySQL/MariaDB host.
public static let host: AbsoluteConfigKey = .init(.Database.host)
/// The absolute configuration key for the MySQL/MariaDB port.
public static let port: AbsoluteConfigKey = .init(.Database.port)
/// The absolute configuration key for the database name.
public static let name: AbsoluteConfigKey = .init(.Database.name)
/// The absolute configuration key for the database username.
public static let username: AbsoluteConfigKey = .init(.Database.username)
/// The absolute configuration key for the database password.
public static let password: AbsoluteConfigKey = .init(.Database.password)
/// The absolute configuration key for the TLS posture used when connecting.
public static let tls: AbsoluteConfigKey = .init(.Database.tls)
/// The absolute configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
}
/// A namespace for the HTTP server configuration keys, as absolute keys.
public enum HTTP {
/// The absolute configuration key for the host the server binds to.
@@ -15,6 +15,27 @@ extension ConfigKey {
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
}
/// A namespace for the persistence configuration keys.
public enum Database {
/// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
public static let migrate: ConfigKey = "database.migrate"
/// The configuration key for the persistence driver (`inMemory` or `mysql`).
public static let driver: ConfigKey = "database.driver"
/// The configuration key for the MySQL/MariaDB host.
public static let host: ConfigKey = "database.host"
/// The configuration key for the MySQL/MariaDB port.
public static let port: ConfigKey = "database.port"
/// The configuration key for the database name.
public static let name: ConfigKey = "database.name"
/// The configuration key for the database username.
public static let username: ConfigKey = "database.username"
/// The configuration key for the database password.
public static let password: ConfigKey = "database.password"
/// The configuration key for the TLS posture used when connecting (`off`, `prefer`, or `require`).
public static let tls: ConfigKey = "database.tls"
/// The configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
}
/// A namespace for the HTTP server configuration keys.
public enum HTTP {
/// The configuration key for the host the server binds to.
@@ -13,4 +13,11 @@ extension Int {
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
public static let minResponseSize = 1_024
}
/// A namespace for the persistence's default configuration values.
public enum Database {
/// The default MySQL/MariaDB port.
public static let port = 3_306
/// The default maximum pooled connections per event loop.
public static let poolMaxPerEventLoop = 4
}
}
@@ -1,4 +1,23 @@
extension String {
/// A namespace for the persistence's default configuration values and recognized tokens.
public enum Database {
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
public static let driver = "inMemory"
/// The driver token selecting the MySQL/MariaDB backend.
public static let driverMySQL = "mysql"
/// The default MySQL/MariaDB host.
public static let host = "localhost"
/// The default database name.
public static let name = "loud"
/// The default database username.
public static let username = "loud"
/// The default TLS posture token.
public static let tls = "prefer"
/// The TLS token disabling TLS.
public static let tlsOff = "off"
/// The TLS token requiring TLS.
public static let tlsRequire = "require"
}
/// A namespace for well-known path string constants.
public enum Path {
/// The directory, relative to the working directory, that the website's static files are served from.
+38
View File
@@ -49,6 +49,44 @@ struct AppTests {
}
}
@Test
func `health check to be served at the health path`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/health",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ok"}"#)
}
}
}
@Test
func `readiness check to be served at the readiness path`() async throws {
// Live mode runs the application's service group, so the `Fluent` service starts before the
// request and shuts its connection pool down after the router-only mode never would.
try await app(
staticFilesPath: staticFilesPath
).test(.live) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
}
}
}
@Test(arguments: StaticFile.allCases)
func `static files to be served`(
staticFile file: StaticFile
@@ -1,6 +1,8 @@
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteCore
@@ -8,21 +10,13 @@ import Testing
@Suite("HealthController controller")
struct HealthControllerTests {
// MARK: Constants
private let app: Application = .init(router: {
let router = Router()
router.addRoutes(HealthController<BasicRequestContext>().routes)
return router
}())
// MARK: Functional tests
@Test
func `serves the status payload at the health path`() async throws {
try await app.test(.router) { client in
try await app(
probe: nil
).test(.router) { client in
try await client.execute(
uri: "/health",
method: .get
@@ -36,4 +30,113 @@ struct HealthControllerTests {
}
}
@Test
func `serves ready at the readiness path when the database is reachable`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
}
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test
func `serves unavailable at the readiness path when the database is unreachable`() async throws {
// Port 1 on the loopback interface has nothing listening, so the probe's connection is refused
// immediately instead of timing out.
let service = Service(
driver: .mysql(
.init(
host: "127.0.0.1",
port: 1,
name: "unreachable",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .serviceUnavailable)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"unavailable"}"#)
}
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test
func `does not serve the readiness path without a probe`() async throws {
try await app(probe: nil).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
#expect(response.status == .notFound)
}
}
}
}
// MARK: - Helpers
private extension HealthControllerTests {
/// Builds a test application serving the ``HealthController`` routes for the given probe.
/// - Parameter probe: the probe supplied to the controller, or `nil` for liveness only.
/// - Returns: the configured test application.
func app(
probe: Probe?
) -> some ApplicationProtocol {
Application(router: {
let router = Router()
router.addRoutes(HealthController<BasicRequestContext>(probe: probe).routes)
return router
}())
}
}
+35 -2
View File
@@ -12,9 +12,42 @@ services:
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
platform: linux/arm64
build:
# The build context is the repo root so the local Localization package
# (referenced via ../../Packages/Localization) is inside the context.
context: ../..
dockerfile: Services/Website/Dockerfile
environment:
LOG_LEVEL: debug
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_TLS: ${DATABASE_TLS:-off}
# Local development database, started only with the `database` profile so a plain
# `docker compose up` still runs the in-memory backend:
#
# docker compose --profile database up mariadb
mariadb:
image: mariadb:11
container_name: ${HOST_OWNER:-loud}-db
restart: unless-stopped
profiles:
- database
ports:
- "127.0.0.1:${DATABASE_PORT:-3306}:3306"
environment:
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
MARIADB_DATABASE: ${DATABASE_NAME:-loud-ams}
MARIADB_USER: ${DATABASE_USERNAME:-loud-ams}
MARIADB_PASSWORD: ${DATABASE_PASSWORD:-loud-ams}
MARIADB_AUTO_UPGRADE: "1"
command:
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci"
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- ./Tests/DB:/var/lib/mysql
+10
View File
@@ -21,3 +21,13 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
# Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a
# managed MySQL/MariaDB database. Provide the password via the environment or a
# secret — never commit it.
DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_PORT: ${DATABASE_PORT:-3306}
DATABASE_NAME: ${DATABASE_NAME:-loud-ams}
DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
DATABASE_TLS: ${DATABASE_TLS:-require}