Project updates from Template #1
@@ -51,6 +51,9 @@ playground.xcworkspace
|
|||||||
.env
|
.env
|
||||||
!.env.local
|
!.env.local
|
||||||
|
|
||||||
|
## Local MariaDB data files
|
||||||
|
Services/Website/Tests/DB/
|
||||||
|
|
||||||
# Fastlane
|
# Fastlane
|
||||||
fastlane/report.xml
|
fastlane/report.xml
|
||||||
fastlane/Preview.html
|
fastlane/Preview.html
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ let package = Package(
|
|||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v15),
|
.macOS(.v15),
|
||||||
.iOS(.v18),
|
|
||||||
.tvOS(.v18),
|
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.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>
|
||||||
@@ -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() {}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -40,3 +40,28 @@ HTTP_SERVER_NAME=LoudWebsite
|
|||||||
|
|
||||||
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
||||||
LOG_LEVEL=info
|
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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ WORKDIR /build
|
|||||||
# not change. The Website package depends on the local Localization package via
|
# 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.
|
# a relative path, so its manifest must be present for resolution to succeed.
|
||||||
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
|
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/
|
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
|
||||||
RUN swift package --package-path ./Services/Website resolve
|
RUN swift package --package-path ./Services/Website resolve
|
||||||
|
|
||||||
|
|||||||
+60
-12
@@ -38,36 +38,84 @@ pkg-clean: ## Remove the Swift build artifacts
|
|||||||
@swift package clean
|
@swift package clean
|
||||||
|
|
||||||
.PHONY: pkg-reset
|
.PHONY: pkg-reset
|
||||||
pkg-reset: ## Resets the complete SPM cache/build folder
|
pkg-reset: ## Reset the SPM cache and build folder
|
||||||
@swift package reset
|
@swift package reset
|
||||||
|
|
||||||
.PHONY: pkg-outdated
|
.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
|
@swift package update --dry-run
|
||||||
|
|
||||||
.PHONY: pkg-update
|
.PHONY: pkg-update
|
||||||
pkg-update: ## Updates the SPM package dependencies
|
pkg-update: ## Update the SPM dependencies
|
||||||
@swift package update
|
@swift package update
|
||||||
|
|
||||||
# --- Local development --------------------------------------------------------
|
# --- Local development --------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: img-build
|
.PHONY: site-run
|
||||||
img-build: ## Build the local dev image
|
site-run: ## Run the website locally, rebuilding on source changes
|
||||||
@docker compose build
|
@hb watch
|
||||||
|
|
||||||
.PHONY: img-mount
|
.PHONY: site-mount
|
||||||
img-mount: ## Mount the service locally (build if needed)
|
site-mount: ## Mount the website locally
|
||||||
@docker compose up --build --detach
|
@docker compose up \
|
||||||
|
--build \
|
||||||
|
--detach
|
||||||
|
|
||||||
.PHONY: img-unmount
|
.PHONY: site-unmount
|
||||||
img-unmount: ## Unmount and remove the local service
|
site-unmount: ## Unmount and remove the local website
|
||||||
@docker compose down
|
@docker compose down
|
||||||
@$(MAKE) img-remove
|
@$(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 ------------------------------------------------------
|
# --- Registry deployment ------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: img-check
|
||||||
|
img-check: ## Check the production image builds
|
||||||
|
@docker build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--file Dockerfile \
|
||||||
|
../..
|
||||||
|
|
||||||
.PHONY: img-release
|
.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 \
|
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
|
||||||
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
|
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ let package = Package(
|
|||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Localization"
|
path: "../../Packages/Localization"
|
||||||
),
|
),
|
||||||
|
.package(
|
||||||
|
path: "../../Packages/Persistence"
|
||||||
|
),
|
||||||
.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"
|
||||||
@@ -52,6 +55,7 @@ let package = Package(
|
|||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "Website",
|
name: "Website",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Persistence"),
|
||||||
.byName(name: "WebsiteCore"),
|
.byName(name: "WebsiteCore"),
|
||||||
.product(
|
.product(
|
||||||
name: "Configuration",
|
name: "Configuration",
|
||||||
@@ -72,6 +76,7 @@ let package = Package(
|
|||||||
name: "WebsiteCore",
|
name: "WebsiteCore",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.byName(name: "Localization"),
|
.byName(name: "Localization"),
|
||||||
|
.byName(name: "Persistence"),
|
||||||
.product(
|
.product(
|
||||||
name: "Configuration",
|
name: "Configuration",
|
||||||
package: "swift-configuration"
|
package: "swift-configuration"
|
||||||
|
|||||||
@@ -5,24 +5,30 @@ 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 `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.
|
- 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`.
|
- 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.
|
||||||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||||||
- Stamps a hardened set of security headers on every response.
|
- 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
|
## Requirements
|
||||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
- Docker (optional) for the containerized run/deploy workflow.
|
- 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
|
## Architecture
|
||||||
Two SwiftPM targets:
|
Two SwiftPM targets:
|
||||||
| Target | Kind | Path | Role |
|
| 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` | 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:
|
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)
|
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
|
||||||
→ FileMiddleware (serves Resources/Static)
|
→ FileMiddleware (serves Resources/Static)
|
||||||
RootController (GET / → landing page)
|
RootController (GET / → landing page)
|
||||||
HealthController (GET /health → health check)
|
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## 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. |
|
| `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
|
### Paths
|
||||||
| Config key | Environment variable | Default | Description |
|
| 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`):
|
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
|
||||||
make img-mount # docker compose up --build --detach
|
make site-run # run locally with hot reload (hb watch)
|
||||||
make img-unmount # docker compose down + remove the local image
|
make site-mount # docker compose up --build --detach
|
||||||
|
make site-unmount # docker compose down + remove the local image
|
||||||
```
|
```
|
||||||
|
|
||||||
`make help` lists every available target.
|
`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
|
## Testing
|
||||||
```sh
|
```sh
|
||||||
make pkg-test
|
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).
|
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
|
## 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`).
|
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):
|
Build, tag, and push a release to the registry (an explicit version is required):
|
||||||
```sh
|
```sh
|
||||||
make img-release version=1.2.3
|
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`). |
|
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||||
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
|
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
|
||||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
| `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
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,17 @@ import Configuration
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
import Logging
|
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
|
@main
|
||||||
struct App {
|
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 {
|
static func main() async throws {
|
||||||
let reader = try await ConfigReader(
|
let reader = try await ConfigReader(
|
||||||
providers: [
|
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(
|
let app = await application(
|
||||||
reader: reader
|
reader: reader
|
||||||
)
|
)
|
||||||
|
|
||||||
try await app.runService()
|
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 Hummingbird
|
||||||
import NIOCore
|
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
|
/// 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:
|
||||||
/// (or a sub-group) by the application that composes it:
|
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```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
|
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
|
||||||
/// context of the router the routes are added to.
|
/// 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 {
|
public struct HealthController<Context: RequestContext>: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The JSON payload returned for every health check.
|
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
|
||||||
private let payload: String
|
private let probe: Probe?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a health controller.
|
/// Creates a health controller.
|
||||||
public init() {
|
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
|
||||||
self.payload = #"{"status":"ok"}"#
|
/// route is served.
|
||||||
|
public init(
|
||||||
|
probe: Probe? = nil
|
||||||
|
) {
|
||||||
|
self.probe = probe
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The routes served by the controller.
|
/// 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> {
|
public var routes: RouteCollection<Context> {
|
||||||
let routes = RouteCollection(context: Context.self)
|
let routes = RouteCollection(context: Context.self)
|
||||||
|
|
||||||
@@ -39,6 +48,13 @@ public struct HealthController<Context: RequestContext>: Sendable {
|
|||||||
use: check
|
use: check
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if probe != nil {
|
||||||
|
routes.get(
|
||||||
|
.Health.ready,
|
||||||
|
use: ready
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +66,12 @@ private extension HealthController {
|
|||||||
|
|
||||||
// MARK: Methods
|
// 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
|
/// 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:
|
/// - Parameters:
|
||||||
/// - request: the incoming request.
|
/// - request: the incoming request.
|
||||||
/// - context: the context the request is resolved against.
|
/// - context: the context the request is resolved against.
|
||||||
@@ -63,8 +81,50 @@ private extension HealthController {
|
|||||||
request: Request,
|
request: Request,
|
||||||
context: some RequestContext
|
context: some RequestContext
|
||||||
) -> Response {
|
) -> Response {
|
||||||
Response(
|
json(
|
||||||
status: .ok,
|
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"],
|
headers: [.contentType: "application/json"],
|
||||||
body: .init(byteBuffer: .init(string: payload))
|
body: .init(byteBuffer: .init(string: payload))
|
||||||
)
|
)
|
||||||
@@ -72,12 +132,24 @@ private extension HealthController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - RouterPath+Constants
|
||||||
|
|
||||||
private extension RouterPath {
|
private extension RouterPath {
|
||||||
/// A namespace for the ``HealthController`` route paths.
|
/// A namespace for the ``HealthController`` route paths.
|
||||||
enum Health {
|
enum Health {
|
||||||
/// The path of the health-check endpoint.
|
/// The path of the liveness endpoint.
|
||||||
static let check: RouterPath = "/health"
|
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.
|
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||||
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
|
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.
|
/// A namespace for the HTTP server configuration keys, as absolute keys.
|
||||||
public enum HTTP {
|
public enum HTTP {
|
||||||
/// The absolute configuration key for the host the server binds to.
|
/// 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.
|
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||||
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
|
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.
|
/// A namespace for the HTTP server configuration keys.
|
||||||
public enum HTTP {
|
public enum HTTP {
|
||||||
/// The configuration key for the host the server binds to.
|
/// 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).
|
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
|
||||||
public static let minResponseSize = 1_024
|
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 {
|
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.
|
/// A namespace for well-known path string constants.
|
||||||
public enum Path {
|
public enum Path {
|
||||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||||
|
|||||||
@@ -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)
|
@Test(arguments: StaticFile.allCases)
|
||||||
func `static files to be served`(
|
func `static files to be served`(
|
||||||
staticFile file: StaticFile
|
staticFile file: StaticFile
|
||||||
|
|||||||
+114
-11
@@ -1,6 +1,8 @@
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
|
import Logging
|
||||||
import NIOCore
|
import NIOCore
|
||||||
|
import Persistence
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import WebsiteCore
|
@testable import WebsiteCore
|
||||||
@@ -8,21 +10,13 @@ import Testing
|
|||||||
@Suite("HealthController controller")
|
@Suite("HealthController controller")
|
||||||
struct HealthControllerTests {
|
struct HealthControllerTests {
|
||||||
|
|
||||||
// MARK: Constants
|
|
||||||
|
|
||||||
private let app: Application = .init(router: {
|
|
||||||
let router = Router()
|
|
||||||
|
|
||||||
router.addRoutes(HealthController<BasicRequestContext>().routes)
|
|
||||||
|
|
||||||
return router
|
|
||||||
}())
|
|
||||||
|
|
||||||
// MARK: Functional tests
|
// MARK: Functional tests
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `serves the status payload at the health path`() async throws {
|
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(
|
try await client.execute(
|
||||||
uri: "/health",
|
uri: "/health",
|
||||||
method: .get
|
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
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,42 @@ services:
|
|||||||
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
build:
|
build:
|
||||||
# The build context is the repo root so the local Localization package
|
|
||||||
# (referenced via ../../Packages/Localization) is inside the context.
|
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: Services/Website/Dockerfile
|
dockerfile: Services/Website/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
LOG_LEVEL: debug
|
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
|
||||||
|
|||||||
@@ -21,3 +21,13 @@ services:
|
|||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
|
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
|
||||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user