2026-08-19 22:55:00 +02:00
import Configuration
import Hummingbird
2026-09-21 13:32:05 +02:00
import HummingbirdFluent
2026-08-19 22:55:00 +02:00
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
2026-09-21 11:16:52 +02:00
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated here, before the landing page reads its listings, while a
/// PostgreSQL backend is migrated out of band (so a shared database is never migrated on boot).
2026-08-19 22:55:00 +02:00
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
2026-09-04 13:40:35 +00:00
/// - 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).
2026-08-19 22:55:00 +02:00
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" )
}
2026-09-04 13:40:35 +00:00
let driver = try reader . driver
2026-08-19 22:55:00 +02:00
let persistence = try Service (
2026-09-04 13:40:35 +00:00
driver : driver ,
2026-08-19 22:55:00 +02:00
logger : logger
)
let fluent = persistence ()
let fingerprintAssets = FingerprintAssets ( logger : logger )
let prepareDB = PrepareDB ()
await prepareDB ( for : fluent )
2026-09-21 11:16:52 +02:00
// The in-memory backend is recreated on every launch, so it is migrated — and seeded — before the landing page
// reads its listings. The PostgreSQL backend is left untouched: a shared database is migrated out of band to avoid
// multi-instance races.
if case . inMemory = driver {
try await fluent . migrate ()
}
let repository = CatalogueRepository ( fluent : fluent )
2026-08-19 22:55:00 +02:00
var app = Application (
router : router (
staticFilesPath : reader . staticFilesPath ,
assetVersion : fingerprintAssets ( reader . staticFilesPath ),
analytics : reader . analytics ,
cacheControl : reader . cacheControl ,
compressionMinResponseSize : reader . compressionMinResponseSize ,
2026-09-04 13:40:35 +00:00
httpsRedirect : reader . httpsRedirect ,
2026-08-19 22:55:00 +02:00
rateLimit : reader . rateLimit ,
securityHeaders : reader . securityHeaders ,
2026-09-04 13:40:35 +00:00
// 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 ,
2026-08-19 22:55:00 +02:00
logLevel : reader . logLevel ,
2026-09-21 12:55:35 +02:00
passkeysOrigin : reader . passkeysOrigin ,
2026-09-21 11:16:52 +02:00
probe : Probe ( fluent : fluent ),
2026-09-21 13:32:05 +02:00
fluent : fluent ,
2026-09-21 11:44:58 +02:00
accounts : AccountRepository ( fluent : fluent ),
2026-09-21 12:11:25 +02:00
trade : TradeRepository ( fluent : fluent ),
2026-09-21 11:16:52 +02:00
repository : repository ,
listings : await homeListings (
from : repository ,
logger : logger
)
2026-08-19 22:55:00 +02:00
),
configuration : ApplicationConfiguration (
reader : reader . scoped ( to : "http" )
),
logger : logger
)
app . addServices ( fluent )
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 (
2026-09-04 13:40:35 +00:00
driver : try reader . driver ,
2026-08-19 22:55:00 +02:00
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
2026-09-21 11:16:52 +02:00
/// Reads the listings the landing page shows, once: the page is rendered at startup and cached.
///
/// A catalogue that cannot be read — a PostgreSQL backend not migrated yet — leaves the page without its rows rather than failing the launch.
/// - Parameters:
/// - repository: the repository the listings are read through.
/// - logger: the logger the failure is reported to.
/// - Returns: the landing page's listings, or none when the catalogue cannot be read.
private func homeListings (
from repository : CatalogueRepository ,
logger : Logger
) async -> HomeListings {
do {
return . init ( from : try await repository . listings ())
} catch {
logger . warning ( "Catalogue is unreadable; the landing page will show no listings: \( error ) " )
return . init ()
}
}
2026-08-19 22:55:00 +02:00
/// 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.
///
2026-09-20 12:45:58 +02:00
/// The chain below reads as its own list; the order is what does not. Security headers sit just inside request logging, so they reach every response a
/// client sees — pages, compressed bodies, the not-found page, the served files. The HTTPS redirect sits directly beneath, keeping those headers while
/// skipping the compression and file lookup it would otherwise pay for. The trailing-slash redirect follows, ahead of the routes and `FileMiddleware`
/// that would otherwise answer both spellings of every path. The language is negotiated by the responders that read it — the landing route and the
/// not-found middleware — rather than on every request past.
2026-08-19 22:55:00 +02:00
/// - 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.
2026-09-04 13:40:35 +00:00
/// - 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.
2026-08-19 22:55:00 +02:00
/// - securityHeaders: the security headers applied to every response.
2026-09-04 13:40:35 +00:00
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
2026-08-19 22:55:00 +02:00
/// - logLevel: the level the request-logging middleware logs at.
2026-09-21 12:55:35 +02:00
/// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to.
2026-08-19 22:55:00 +02:00
/// - probe: the probe consulted by the `HealthController` readiness route.
2026-09-21 13:32:05 +02:00
/// - fluent: the service the remaining repositories are built on.
2026-09-21 11:44:58 +02:00
/// - accounts: the repository the `SessionController` finds and creates accounts through.
2026-09-21 12:11:25 +02:00
/// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through.
2026-09-21 11:16:52 +02:00
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
/// - listings: the listings the landing page shows in its rows.
2026-08-19 22:55:00 +02:00
/// - Returns: the configured router.
private func router (
staticFilesPath : String ,
assetVersion : String ?,
analytics : Analytics ?,
cacheControl : CacheControl ,
compressionMinResponseSize : Int ,
2026-09-04 13:40:35 +00:00
httpsRedirect : HTTPSRedirectMiddleware < AppRequestContext >. Configuration ,
2026-08-19 22:55:00 +02:00
rateLimit : RateLimitMiddleware < AppRequestContext >. Configuration ,
securityHeaders : SecurityHeadersMiddleware < AppRequestContext >. Configuration ,
2026-09-04 13:40:35 +00:00
siteOrigin : String ?,
2026-08-19 22:55:00 +02:00
logLevel : Logger . Level ,
2026-09-21 12:55:35 +02:00
passkeysOrigin : String ,
2026-09-21 11:16:52 +02:00
probe : Probe ,
2026-09-21 13:32:05 +02:00
fluent : Fluent ,
2026-09-21 11:44:58 +02:00
accounts : AccountRepository ,
2026-09-21 12:11:25 +02:00
trade : TradeRepository ,
2026-09-21 11:16:52 +02:00
repository : CatalogueRepository ,
listings : HomeListings
2026-08-19 22:55:00 +02:00
) -> 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
)
2026-09-21 13:32:05 +02:00
let collecting = CollectingRepository ( fluent : fluent )
let sessions = SessionStore (
trade : trade ,
collecting : collecting
)
let root = RootController < AppRequestContext >(
assetVersion : assetVersion ,
siteOrigin : siteOrigin ,
analytics : analytics ,
listings : listings ,
sessions : sessions
)
// A sale takes a copy off the landing page and a new listing puts one on; either renders it again.
let refreshHome : @ Sendable () async -> Void = { [ home = root . home ] in
guard let listings = try ? await repository . listings () else {
return
}
await home . refresh ( with : . init ( from : listings ))
}
2026-08-19 22:55:00 +02:00
router . addMiddleware {
LogRequestsMiddleware ( logLevel )
SecurityHeadersMiddleware (
configuration : securityHeaders
)
2026-09-04 13:40:35 +00:00
HTTPSRedirectMiddleware (
configuration : httpsRedirect
)
TrailingSlashRedirectMiddleware ()
2026-08-19 22:55:00 +02:00
VaryMiddleware ()
2026-09-20 12:45:58 +02:00
CompressionMiddleware (
2026-08-19 22:55:00 +02:00
minimumResponseSizeToCompress : compressionMinResponseSize
)
NotFoundMiddleware (
assetVersion : assetVersion ,
analytics : analytics
)
FileMiddleware (
staticFilesPath ,
cacheControl : cacheControl
)
}
router . addController {
2026-09-21 13:32:05 +02:00
root
2026-09-21 11:16:52 +02:00
CatalogueController < AppRequestContext >(
repository : repository ,
2026-09-21 11:44:58 +02:00
sessions : sessions ,
assetVersion : assetVersion ,
analytics : analytics
)
SessionController < AppRequestContext >(
sessions : sessions ,
accounts : accounts ,
catalogue : repository ,
// Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
isSecure : httpsRedirect . trustForwardedProto && httpsRedirect . origin . hasPrefix ( "https://" ),
2026-09-21 11:16:52 +02:00
assetVersion : assetVersion ,
2026-08-19 22:55:00 +02:00
analytics : analytics
)
2026-09-21 13:32:05 +02:00
AccountController < AppRequestContext >(
accounts : accounts ,
catalogue : repository ,
sessions : sessions ,
assetVersion : assetVersion ,
analytics : analytics
)
CollectingController < AppRequestContext >(
collecting : collecting ,
catalogue : repository ,
sessions : sessions ,
assetVersion : assetVersion ,
analytics : analytics
)
2026-09-21 12:55:35 +02:00
PasskeyController < AppRequestContext >(
origin : passkeysOrigin ,
accounts : accounts ,
sessions : sessions
)
ListingFormController < AppRequestContext >(
catalogue : repository ,
trade : trade ,
sessions : sessions ,
photos : PhotoStore ( staticFilesPath : staticFilesPath ),
assetVersion : assetVersion ,
2026-09-21 13:32:05 +02:00
analytics : analytics ,
onCatalogueChange : refreshHome
2026-09-21 12:55:35 +02:00
)
2026-09-21 12:11:25 +02:00
TradeController < AppRequestContext >(
trade : trade ,
catalogue : repository ,
sessions : sessions ,
assetVersion : assetVersion ,
2026-09-21 13:32:05 +02:00
analytics : analytics ,
onCatalogueChange : refreshHome
2026-09-21 12:11:25 +02:00
)
2026-08-19 22:55:00 +02:00
HealthController < AppRequestContext >(
probe : probe
)
}
return router
}