Files
ccn/Services/Website/Sources/App/Extensions/App+Build.swift
T
javier 65b62681eb Project updates from Template (#1)
This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
2026-09-04 13:40:35 +00:00

226 lines
9.3 KiB
Swift

import Configuration
import Hummingbird
import HummingbirdCompression
import Localization
import Logging
import Persistence
import Infrastructure
import WebsiteLibrary
/// 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 warns when the localization catalog cannot be read, since pages would serve raw localization keys.
/// 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 PostgreSQL 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.
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
/// (e.g. its TLS context fails to build).
func application(
reader: ConfigReader
) async throws -> some ApplicationProtocol {
let languages = LanguageList()
let logger = logger(
serverName: reader.serverName,
logLevel: reader.logLevel
)
// A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to
// visitors — surface it here instead.
if languages.catalogState != .loaded {
let isCatalogMissing = languages.catalogState == .missing
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
}
let driver = try reader.driver
let persistence = try Service(
driver: driver,
logger: logger
)
let fluent = persistence()
let fingerprintAssets = FingerprintAssets(logger: logger)
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
assetVersion: fingerprintAssets(reader.staticFilesPath),
analytics: reader.analytics,
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
httpsRedirect: reader.httpsRedirect,
rateLimit: reader.rateLimit,
securityHeaders: reader.securityHeaders,
// An unset origin leaves the pages without canonical URLs and language alternates, rather than
// building both against an empty host.
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
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 PostgreSQL backend is
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = 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 PostgreSQL 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 = try Service(
driver: try 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
/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the trailing-slash redirect middleware that
/// collapses each path onto its canonical form, the vary middleware that marks every response as varying on `Accept-Encoding`, 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 (honouring the `lang` query override and the language a leading path segment names), the
/// not-found middleware that serves the not-found 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 — one per language the String Catalog serves — 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 not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security
/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. The trailing-slash redirect follows it, ahead of the
/// routes and `FileMiddleware` that would otherwise answer both spellings of every path.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted.
/// - rateLimit: the rate limit configuration, currently applied to no route.
/// - securityHeaders: the security headers applied to every response.
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
/// - 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,
assetVersion: String?,
analytics: Analytics?,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
siteOrigin: String?,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
// the page's status and headers instead of a 404.
let router = Router(
context: AppRequestContext.self,
options: .autoGenerateHeadEndpoints
)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
HTTPSRedirectMiddleware(
configuration: httpsRedirect
)
TrailingSlashRedirectMiddleware()
VaryMiddleware()
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware(
assetVersion: assetVersion,
analytics: analytics
)
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addController {
RootController<AppRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
)
HealthController<AppRequestContext>(
probe: probe
)
}
return router
}