Tweaks and fixes throughout the project (#27)

This PR contains the work done to do a little bit of housekeeping pass across all packages and the Website service.

To provide further details about the work:
* Refreshed the READMEs and source documentation to match the current code;
* Tagged every test case consistently across the Infrastructure, Localization, Persistence, and Website test targets;
* Removed Website middleware tests now covered by Infrastructure's own suite;
* Conformed the `PrepareDB` method to Sendable;
* Relaxes the production Compose DATABASE_TLS default from require to prefer;
* Added Persistence test verifying the prefer posture falls back to plaintext connections.

Reviewed-on: rock-n-code/loud-amsterdam#27
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-30 06:33:57 +00:00
committed by javier
parent 0887135328
commit ea00b94841
71 changed files with 633 additions and 512 deletions
+20 -8
View File
@@ -3,7 +3,6 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
## Overview
The package provides, grouped by role:
| Role | Types |
| --- | --- |
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
@@ -11,23 +10,36 @@ The package provides, grouped by role:
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
| Contexts | `LocalizedRequestContext` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
## Design rules
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow.
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
## Layout
Sources are split by visibility, then by kind, one type per file:
```
Sources/
├── Public/ public API
│ ├── Builders/ RouteCollectionBuilder
│ ├── Enumerations/ AssetExtension
│ ├── Extensions/ addController, plus the default header names and values
│ ├── Methods/ FingerprintAssets
│ ├── Middlewares/ the five HTTP middlewares
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
└── Internal/
└── Types/ implementation details (FNV1aHash)
Tests/
├── Cases/ the test suites, mirroring the Sources/ layout
├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux
└── Utils/ stubs (StubAsset, StubPage, …) and the suite Tag constants
```
```
Sources/Public/<Kind>/ public API (Protocols, Middlewares, Responses, …)
Sources/Internal/<Kind>/ implementation details (e.g. FNV1aHash)
Tests/Cases/… mirrors the source layout
Tests/Utils/… stubs and test-only extensions
```
## Testing
Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
@@ -2,9 +2,8 @@ import Foundation
/// Hashes bytes with the FNV-1a 64-bit algorithm.
///
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it
/// suits values that must agree between instances and survive restarts: the asset version token
/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`).
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive
/// restarts: the asset version token (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`).
/// It is not cryptographic a collision only risks serving a stale cached asset, not security.
struct FNV1aHash {
@@ -2,8 +2,8 @@ import Hummingbird
/// A result builder that collects the route collections of ``RouterController`` values into a stack.
///
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting
/// controllers be listed declaratively rather than having their routes added one statement at a time.
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting controllers be listed declaratively rather than having
/// their routes added one statement at a time.
@resultBuilder
public enum RouteCollectionBuilder<Context: RequestContext> {
@@ -1,7 +1,6 @@
/// A file extension used by an ``Asset``.
///
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its
/// file name when resolving paths.
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths.
public enum AssetExtension: String, Sendable {
/// A Cascading Style Sheets file.
case css
@@ -4,8 +4,7 @@ public extension RouterMethods {
// MARK: Methods
/// Adds the routes of ``RouterController`` values to the router using the
/// ``RouteCollectionBuilder`` result builder.
/// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder.
///
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
///
@@ -16,8 +15,7 @@ public extension RouterMethods {
/// }
/// ```
///
/// Each controller's route collection is added at the router's root, exactly as a
/// sequence of `addRoutes(_:)` calls would.
/// Each controller's route collection is added at the router's root, exactly as a sequence of `addRoutes(_:)` calls would.
/// - Parameter build: the controller stack result builder.
/// - Returns: the router, so calls can be chained.
@discardableResult
@@ -1,15 +1,14 @@
extension String {
/// A namespace for the security headers' default configuration values.
///
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is "sticky" in browsers, so it stays off unless explicitly
/// configured in production.
public enum Security {
/// The default `Content-Security-Policy`.
///
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins
/// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing
/// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link
/// external stylesheets.
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document
/// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must
/// link external stylesheets.
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/// The default `X-Content-Type-Options` (disables MIME sniffing).
public static let contentTypeOptions = "nosniff"
@@ -28,9 +28,8 @@ public struct FingerprintAssets: Sendable {
/// Fingerprints the static files under the given directory.
///
/// A file that cannot be read is reported to the ``logger`` and left out of the token, so its
/// later changes would not bust caches a warning there usually points at a permissions
/// problem in the deployment.
/// A file that cannot be read is reported to the ``logger`` and left out of the token, so its later changes would not bust caches a warning there
/// usually points at a permissions problem in the deployment.
/// - Parameter path: the directory the static files are served from.
/// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned).
public func callAsFunction(
@@ -42,10 +41,9 @@ public struct FingerprintAssets: Sendable {
return nil
}
// The path-based enumerator yields paths relative to the directory, so the token depends
// only on the directory's contents never on where the directory itself lives (the
// URL-based enumerator standardizes symlinked bases, e.g. `/var/` to `/private/var/`,
// which would leak the absolute path into the hash).
// The path-based enumerator yields paths relative to the directory, so the token depends only on the
// directory's contents never on where the directory itself lives (the URL-based enumerator standardizes
// symlinked bases, e.g. `/var/` to `/private/var/`, which would leak the absolute path into the hash).
var files: [String] = []
while let relativePath = enumerated.nextObject() as? String {
@@ -5,12 +5,11 @@ import Localization
/// Resolves the visitor's preferred language and records it on the request context.
///
/// Placed ahead of the localized responders in the middleware chain, it reads the request's
/// `Accept-Language` header, negotiates the best supported match (falling back to the default
/// language), and stores it on the context's ``LocalizedRequestContext/language``.
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
///
/// The request is otherwise passed through untouched the URL and routing are not affected so
/// each page is served at its existing path and varies its content by header.
/// The request is otherwise passed through untouched the URL and routing are not affected so each page is served at its existing path and varies its
/// content by header.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -3,13 +3,12 @@ import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
/// response bubbles back up the rendered pages, the error page produced by
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up the rendered pages, the
/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies the strict, hardened
/// interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
/// every request, so the per-request cost is a handful of header copies.
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of
/// header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
@@ -20,9 +19,8 @@ public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
/// ``Configuration``).
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with
/// `Strict-Transport-Security` left off (see ``Configuration``).
public init(
configuration: Configuration = .init()
) {
@@ -37,14 +35,11 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the
/// response on the way back up.
/// Passes the request down the chain and stamps the configured security headers onto the response on the way back up.
///
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the
/// controllers) are converted to their response here rather than left to the router: the router
/// converts them above the middleware chain, where the response would escape these headers.
/// Existing values for the same header names are replaced so downstream middleware cannot leave
/// a weaker policy in place.
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response
/// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers.
/// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
@@ -106,10 +101,9 @@ private extension SecurityHeadersMiddleware.Configuration {
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely,
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
/// switched on (via configuration) only in TLS-terminated production.
/// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays
/// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on
/// (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
@@ -131,9 +125,8 @@ extension SecurityHeadersMiddleware {
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
/// to drop it from the response.
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults
/// to `nil` (omitted). Pass `nil` for any header to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
@@ -1,9 +1,7 @@
/// An asset shipped with a website: a file stored under the static files root and served by
/// Hummingbird's `FileMiddleware` middleware.
/// An asset shipped with a website: a file stored under the static files root and served by Hummingbird's `FileMiddleware` middleware.
///
/// A conforming asset supplies its file name and the extensions it is available with, each
/// resolving to its own file; the protocol derives the paths from them: the file's path within
/// the static files root and the URL path it is served at, optionally versioned to bust caches.
/// A conforming asset supplies its file name and the extensions it is available with, each resolving to its own file; the protocol derives the paths from them:
/// the file's path within the static files root and the URL path it is served at, optionally versioned to bust caches.
public protocol Asset: Sendable {
// MARK: Properties
@@ -58,9 +56,8 @@ public extension Asset {
/// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`).
///
/// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`):
/// `FileMiddleware` ignores the query when resolving the file, while caches key on the full
/// URL, so a deploy that changes the assets busts every cached copy at once.
/// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): `FileMiddleware` ignores the query when
/// resolving the file, while caches key on the full URL, so a deploy that changes the assets busts every cached copy at once.
/// - Parameters:
/// - fileExtension: the extension of the file to resolve.
/// - version: the version token to append, or `nil` to leave the URL unversioned.
@@ -2,9 +2,8 @@ import Hummingbird
/// A request context that carries the language negotiated for the request.
///
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language`
/// header and stores it here, so downstream controllers and middleware can serve the matching
/// localization without re-reading the header.
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` header and stores it here, so downstream
/// controllers and middleware can serve the matching localization without re-reading the header.
public protocol LocalizedRequestContext: RequestContext {
// MARK: Properties
@@ -62,8 +62,8 @@ public extension Page {
/// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already
/// emits `<meta charset="UTF-8">` before this markup, and HTML5 allows only one.
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
/// and HTML5 allows only one.
@HTMLBuilder
var head: some HTML {
meta(
@@ -2,8 +2,8 @@ import Hummingbird
/// A type exposing its endpoints as a route collection ready to be added to a router.
///
/// Conforming controllers group related endpoints behind a single ``routes`` property,
/// so the application composes them declaratively with ``Hummingbird/RouterMethods/addController(_:)``:
/// Conforming controllers group related endpoints behind a single ``routes`` property, so the application composes them declaratively with
/// ``Hummingbird/RouterMethods/addController(_:)``:
///
/// ```swift
/// struct HealthController<Context: RequestContext>: RouterController {
@@ -6,10 +6,9 @@ import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList``
/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by
/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so
/// shared caches key on the negotiated language instead of serving one language to everyone.
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone.
public struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
@@ -54,8 +53,8 @@ public struct LocalizedHTMLCollectionResponse: Sendable {
/// - Parameters:
/// - language: the negotiated language identifier.
/// - request: the request the response answers, consulted for conditional revalidation.
/// - Returns: the cached response for the language, the default language's response when the
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
/// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a
/// `500 Internal Server Error` if neither is cached.
public func response(
for language: String,
request: Request
@@ -2,7 +2,10 @@ import Testing
@testable import Infrastructure
@Suite("FNV1aHash type")
@Suite(
"FNV1aHash type",
.tags(.type)
)
struct FNV1aHashTests {
// MARK: Functional tests
@@ -2,7 +2,10 @@ import Testing
@testable import Infrastructure
@Suite("AssetExtension enumeration")
@Suite(
"AssetExtension enumeration",
.tags(.asset)
)
struct AssetExtensionTests {
// MARK: Computed tests
@@ -5,7 +5,10 @@ import Testing
@testable import Infrastructure
@Suite("addController method")
@Suite(
"addController method",
.tags(.`extension`)
)
struct RouterMethodsTests {
// MARK: Functional tests
@@ -3,7 +3,10 @@ import Testing
@testable import Infrastructure
@Suite("FingerprintAssets method")
@Suite(
"FingerprintAssets method",
.tags(.asset)
)
struct FingerprintAssetsTests {
// MARK: Properties
@@ -7,7 +7,10 @@ import Testing
@testable import Infrastructure
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
@Suite(
"LocalizationMiddleware middleware",
.tags(.middleware)
)
struct LocalizationMiddlewareTests {
// MARK: Constants
@@ -6,7 +6,10 @@ import Testing
@testable import Infrastructure
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
@Suite(
"NotFoundMiddleware middleware",
.tags(.middleware)
)
struct NotFoundMiddlewareTests {
// MARK: Constants
@@ -4,7 +4,10 @@ import Testing
@testable import Infrastructure
@Suite("RateLimitMiddleware middleware", .tags(.middleware))
@Suite(
"RateLimitMiddleware middleware",
.tags(.middleware)
)
struct RateLimitMiddlewareTests {
// MARK: Functional tests
@@ -4,7 +4,10 @@ import Testing
@testable import Infrastructure
@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware))
@Suite(
"SecurityHeadersMiddleware middleware",
.tags(.middleware)
)
struct SecurityHeadersMiddlewareTests {
// MARK: Functional tests
@@ -5,7 +5,10 @@ import Testing
@testable import Infrastructure
@Suite("VaryMiddleware middleware", .tags(.middleware))
@Suite(
"VaryMiddleware middleware",
.tags(.middleware)
)
struct VaryMiddlewareTests {
// MARK: Functional tests
@@ -2,7 +2,10 @@ import Testing
@testable import Infrastructure
@Suite("Asset protocol")
@Suite(
"Asset protocol",
.tags(.`protocol`)
)
struct AssetTests {
// MARK: Properties
@@ -4,7 +4,10 @@ import Testing
@testable import Infrastructure
@Suite("Page protocol", .tags(.page))
@Suite(
"Page protocol",
.tags(.`protocol`)
)
struct PageTests {
// MARK: Functional tests
@@ -1,8 +1,14 @@
import Testing
extension Tag {
/// Tests exercising the asset scaffolding of the Infrastructure package.
@Tag static var asset: Tag
/// Tests exercising an extension of the Infrastructure package.
@Tag static var `extension`: Tag
/// Tests exercising a middleware of the Infrastructure package.
@Tag static var middleware: Tag
/// Tests exercising the page scaffolding of the Infrastructure package.
@Tag static var page: Tag
/// Tests exercising a protocol scaffolding of the Infrastructure package.
@Tag static var `protocol`: Tag
/// Tests exercising an internal type of the Infrastructure package.
@Tag static var type: Tag
}