diff --git a/.dockerignore b/.dockerignore
index a60105d..b84d7f9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,9 +13,10 @@
# Xcode project (not used by the Linux build)
*.xcodeproj
-# OS / editor cruft
+# OS / editor / tooling cruft
**/.DS_Store
.vscode
+.claude
# Local environment overrides and secrets (Compose still reads these from the
# host at runtime; ignoring them here only keeps them out of the image build).
diff --git a/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme
new file mode 100644
index 0000000..56914ab
--- /dev/null
+++ b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift
new file mode 100644
index 0000000..e399fba
--- /dev/null
+++ b/Packages/Infrastructure/Package.swift
@@ -0,0 +1,68 @@
+// swift-tools-version: 6.3
+
+import PackageDescription
+
+let package = Package(
+ name: "Infrastructure",
+ platforms: [
+ .macOS(.v15),
+ ],
+ products: [
+ .library(
+ name: "Infrastructure",
+ targets: [
+ "Infrastructure"
+ ]
+ ),
+ ],
+ dependencies: [
+ .package(
+ path: "../Localization"
+ ),
+ .package(
+ url: "https://github.com/elementary-swift/elementary.git",
+ from: "0.6.0"
+ ),
+ .package(
+ url: "https://github.com/hummingbird-project/hummingbird.git",
+ from: "2.25.0"
+ ),
+ ],
+ targets: [
+ .target(
+ name: "Infrastructure",
+ dependencies: [
+ .byName(name: "Localization"),
+ .product(
+ name: "Elementary",
+ package: "elementary"
+ ),
+ .product(
+ name: "Hummingbird",
+ package: "hummingbird"
+ ),
+ ],
+ path: "Sources"
+ ),
+ .testTarget(
+ name: "InfrastructureTests",
+ dependencies: [
+ .byName(name: "Infrastructure"),
+ .product(
+ name: "Elementary",
+ package: "elementary"
+ ),
+ .product(
+ name: "HummingbirdTesting",
+ package: "hummingbird"
+ ),
+ ],
+ path: "Tests",
+ resources: [
+ // Copied verbatim rather than processed: the String Catalog is read as raw JSON at
+ // runtime so it resolves identically on Darwin and Linux (which cannot compile it).
+ .copy("Catalogs/Localizable.xcstrings")
+ ]
+ ),
+ ]
+)
diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md
new file mode 100644
index 0000000..b724c15
--- /dev/null
+++ b/Packages/Infrastructure/README.md
@@ -0,0 +1,50 @@
+# Infrastructure
+The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
+
+## Overview
+The package provides, grouped by role:
+| Role | Types |
+| --- | --- |
+| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
+| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
+| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
+| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
+| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
+| 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 — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them.
+- **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. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need.
+- **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
+│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/
+└── Internal/
+ ├── Extensions/ implementation details (the String separators)
+ └── 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
+```
+
+## 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`).
+- macOS 15, matching the sibling `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms).
diff --git a/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift
new file mode 100644
index 0000000..758fc4d
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift
@@ -0,0 +1,5 @@
+extension String {
+ enum Separator {
+ static let comma = ","
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift
new file mode 100644
index 0000000..4165586
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift
@@ -0,0 +1,46 @@
+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`).
+/// It is not cryptographic — a collision only risks serving a stale cached asset, not security.
+struct FNV1aHash {
+
+ // MARK: Properties
+
+ /// The running hash value.
+ private var hash: UInt64
+
+ // MARK: Initializers
+
+ /// Creates a hasher at the FNV-1a offset basis.
+ init() {
+ self.hash = 0xcbf2_9ce4_8422_2325
+ }
+
+ // MARK: Computed
+
+ /// The hash of everything combined so far, as a fixed-width, 16-character hexadecimal token.
+ ///
+ /// Reading it does not consume the running hash: more bytes can be combined afterwards.
+ var digest: String {
+ String(
+ format: "%016llx",
+ hash
+ )
+ }
+
+ // MARK: Functions
+
+ /// Folds the given bytes into the hash.
+ /// - Parameter bytes: the bytes to fold in.
+ mutating func combine(
+ _ bytes: some Sequence
+ ) {
+ for byte in bytes {
+ hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3
+ }
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift
new file mode 100644
index 0000000..a201c2e
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift
@@ -0,0 +1,46 @@
+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.
+@resultBuilder
+public enum RouteCollectionBuilder {
+
+ public static func buildExpression(
+ _ controller: some RouterController
+ ) -> [RouteCollection] {
+ [controller.routes]
+ }
+
+ public static func buildBlock(
+ _ collections: [RouteCollection]...
+ ) -> [RouteCollection] {
+ collections.flatMap { $0 }
+ }
+
+ public static func buildOptional(
+ _ collections: [RouteCollection]?
+ ) -> [RouteCollection] {
+ collections ?? []
+ }
+
+ public static func buildEither(
+ first collections: [RouteCollection]
+ ) -> [RouteCollection] {
+ collections
+ }
+
+ public static func buildEither(
+ second collections: [RouteCollection]
+ ) -> [RouteCollection] {
+ collections
+ }
+
+ public static func buildArray(
+ _ collections: [[RouteCollection]]
+ ) -> [RouteCollection] {
+ collections.flatMap { $0 }
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift
new file mode 100644
index 0000000..9d1ae00
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift
@@ -0,0 +1,52 @@
+/// 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.
+public enum AssetExtension: String, Sendable {
+ /// A Cascading Style Sheets file.
+ case css
+ /// A JavaScript file.
+ case js
+ /// A Portable Network Graphics image.
+ case png
+ /// A Windows icon image.
+ case ico
+ /// A Scalable Vector Graphics image.
+ case svg
+ /// A plain text file.
+ case txt
+ /// A web application manifest file.
+ case webmanifest
+ /// An Extensible Markup Language file.
+ case xml
+}
+
+// MARK: - Extensions
+
+public extension AssetExtension {
+
+ // MARK: Computed
+
+ /// The file's content type.
+ var contentType: String {
+ switch self {
+ case .css: "text/css"
+ case .js: "text/javascript"
+ case .png: "image/png"
+ case .ico: "image/vnd.microsoft.icon"
+ case .svg: "image/svg+xml"
+ case .txt: "text/plain"
+ case .webmanifest: "application/manifest+json"
+ case .xml: "application/xml"
+ }
+ }
+
+ /// The sub-directory within the static root that holds files with this extension, if any.
+ var subdirectory: String? {
+ switch self {
+ case .css: "css"
+ case .js: "js"
+ default: nil
+ }
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift
similarity index 72%
rename from Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift
rename to Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift
index 4ebf3bb..9f89f0a 100644
--- a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift
+++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift
@@ -1,10 +1,12 @@
import HTTPTypes
-extension HTTPField.Name {
+public extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
static let frameOptions = Self("X-Frame-Options")!
+ /// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`).
+ static let xForwardedFor = Self("X-Forwarded-For")!
}
diff --git a/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift
new file mode 100644
index 0000000..b08ed06
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift
@@ -0,0 +1,9 @@
+extension Int {
+ /// A namespace for the rate limit's default configuration values.
+ public enum RateLimit {
+ /// The default number of requests admitted per client per window.
+ public static let limit = 5
+ /// The default window length, in seconds (1 minute).
+ public static let window = 60
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift
new file mode 100644
index 0000000..25c55f2
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift
@@ -0,0 +1,32 @@
+import Hummingbird
+
+public extension RouterMethods {
+
+ // MARK: Methods
+
+ /// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder.
+ ///
+ /// Mirrors `addMiddleware`, letting controllers be listed declaratively:
+ ///
+ /// ```swift
+ /// router.addController {
+ /// RootController()
+ /// HealthController()
+ /// }
+ /// ```
+ ///
+ /// 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
+ func addController(
+ @RouteCollectionBuilder _ build: () -> [RouteCollection]
+ ) -> Self {
+ for collection in build() {
+ addRoutes(collection)
+ }
+
+ return self
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift
new file mode 100644
index 0000000..2791b1e
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift
@@ -0,0 +1,22 @@
+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.
+ 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.
+ 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"
+ /// The default `X-Frame-Options` (forbids framing the page).
+ public static let frameOptions = "DENY"
+ /// The default `Referrer-Policy`.
+ public static let referrerPolicy = "strict-origin-when-cross-origin"
+ /// The default `Permissions-Policy` (denies access to powerful browser features a static site does not use).
+ public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift
new file mode 100644
index 0000000..70a2097
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift
@@ -0,0 +1,83 @@
+import Foundation
+import Logging
+
+/// Derives a version token from the contents of the static files directory.
+///
+/// The token folds every file under the directory — its relative path and its bytes, in a stable order — into one FNV-1a digest, so it changes whenever any
+/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=`), which lets the assets be
+/// served with a long-lived, immutable cache policy: a deploy that changes an asset changes the URLs pointing at it, so no client ever revalidates or holds a
+/// stale copy.
+public struct FingerprintAssets: Sendable {
+
+ // MARK: Properties
+
+ /// The logger unreadable files are reported to, or `nil` to skip them silently.
+ private let logger: Logger?
+
+ // MARK: Initializers
+
+ /// Creates an asset fingerprinting method.
+ /// - Parameter logger: the logger unreadable files are reported to, or `nil` (the default) to skip them silently.
+ public init(
+ logger: Logger? = nil
+ ) {
+ self.logger = logger
+ }
+
+ // MARK: Functions
+
+ /// 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.
+ /// - 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(
+ _ path: String
+ ) -> String? {
+ let manager = FileManager.default
+
+ guard let enumerated = manager.enumerator(atPath: path) else {
+ 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).
+ var files: [String] = []
+
+ while let relativePath = enumerated.nextObject() as? String {
+ if enumerated.fileAttributes?[.type] as? FileAttributeType == .typeRegular {
+ files.append(relativePath)
+ }
+ }
+
+ var hash = FNV1aHash()
+ var hashed = false
+
+ for relativePath in files.sorted() {
+ guard let contents = manager.contents(
+ atPath: "\(path)/\(relativePath)"
+ ) else {
+ logger?.warning(
+ "Static file could not be read while fingerprinting; the version token will not reflect it.",
+ metadata: ["path": "\(relativePath)"]
+ )
+
+ continue
+ }
+
+ hash.combine(Array(relativePath.utf8))
+ hash.combine(contents)
+
+ hashed = true
+ }
+
+ guard hashed else {
+ return nil
+ }
+
+ return hash.digest
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift
similarity index 71%
rename from Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift
rename to Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift
index db18270..964909c 100644
--- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift
+++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift
@@ -1,15 +1,15 @@
+import Foundation
import HTTPTypes
import Hummingbird
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 {
// MARK: Properties
@@ -19,9 +19,12 @@ public struct LocalizationMiddleware {
// MARK: Initializers
- /// Creates a localization middleware that negotiates against the module's String Catalog languages.
- public init() {
- self.negotiate = .init(bundle: .module)
+ /// Creates a localization middleware that negotiates against the given bundle's String Catalog languages.
+ /// - Parameter bundle: the bundle whose String Catalog names the supported languages.
+ public init(
+ bundle: Bundle
+ ) {
+ self.negotiate = .init(bundle: bundle)
}
}
diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift
similarity index 62%
rename from Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift
rename to Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift
index 6d053aa..b5f51e6 100644
--- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift
+++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift
@@ -1,11 +1,12 @@
+import Elementary
+import Foundation
import Hummingbird
/// Serves a custom error page for requests that match neither a route nor a static file.
///
-/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error
-/// that bubbles up when no file exists for the requested path and responds with the rendered
-/// ``ErrorPage`` and a `404 Not Found` status. The page is served in the language stored on the
-/// context by ``LocalizationMiddleware``, falling back to the default language.
+/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
+/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
+/// ``LocalizationMiddleware``, falling back to the default language.
public struct NotFoundMiddleware {
// MARK: Properties
@@ -16,12 +17,18 @@ public struct NotFoundMiddleware {
// MARK: Initializers
/// Creates a not-found middleware.
- public init() {
+ /// - Parameters:
+ /// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
+ /// - document: builds the error page to render for a given locale.
+ public init(
+ bundle: Bundle,
+ document: (Locale) -> Document
+ ) {
self.responses = .init(
- status: .notFound
- ) {
- ErrorPage(locale: $0)
- }
+ bundle: bundle,
+ status: .notFound,
+ document: document
+ )
}
}
@@ -32,15 +39,14 @@ extension NotFoundMiddleware: RouterMiddleware {
// MARK: Functions
- /// Passes the request down the chain, rendering the error page if it results in a not-found
- /// response.
+ /// Passes the request down the chain, rendering the error page if it results in a not-found response.
///
/// Any error other than `.notFound` is rethrown unchanged.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
- /// - Returns: the downstream response, or the rendered ``ErrorPage`` with a `404 Not Found` status.
+ /// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status.
/// - Throws: any non-not-found error thrown downstream.
public func handle(
_ request: Request,
@@ -59,7 +65,8 @@ extension NotFoundMiddleware: RouterMiddleware {
}
return responses.response(
- for: context.language
+ for: context.language,
+ request: request
)
}
}
diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift
new file mode 100644
index 0000000..d1a0f19
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift
@@ -0,0 +1,291 @@
+import Foundation
+import HTTPTypes
+import Hummingbird
+import NIOCore
+import Synchronization
+
+/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit.
+///
+/// Added to the routes that must not be hammered — the subscription endpoint, an unauthenticated database write — it admits up to the configured limit of
+/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the
+/// window resets.
+///
+/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by
+/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the
+/// store without bound — and each instance of a multi-instance deployment enforces its own budget.
+public struct RateLimitMiddleware: Sendable {
+
+ // MARK: Properties
+
+ /// The fixed-window request counters, keyed by client.
+ private let buckets: Buckets
+
+ /// The limits the middleware enforces.
+ private let configuration: Configuration
+
+ // MARK: Initializers
+
+ /// Creates a rate-limit middleware.
+ /// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per
+ /// client per minute.
+ public init(
+ configuration: Configuration = .init()
+ ) {
+ self.buckets = .init(
+ limit: configuration.limit,
+ window: configuration.window
+ )
+ self.configuration = configuration
+ }
+
+}
+
+// MARK: - RouterMiddleware
+
+extension RateLimitMiddleware: RouterMiddleware {
+
+ // MARK: Functions
+
+ /// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After`
+ /// header once it does not.
+ /// - Parameters:
+ /// - request: the incoming request.
+ /// - context: the context the request is resolved against.
+ /// - next: the next responder in the middleware chain.
+ /// - Returns: the downstream response, or the `429` rejection.
+ /// - Throws: any error thrown downstream.
+ public func handle(
+ _ request: Request,
+ context: Context,
+ next: (Request, Context) async throws -> Response
+ ) async throws -> Response {
+ let admission = buckets.admit(
+ client(
+ for: request,
+ context: context
+ )
+ )
+
+ switch admission {
+ case .admitted:
+ return try await next(
+ request,
+ context
+ )
+ case .limited(let retryAfter):
+ var response = Response(status: .tooManyRequests)
+
+ response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds))
+
+ return response
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension RateLimitMiddleware {
+
+ // MARK: Methods
+
+ /// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one
+ /// bucket shared by every unidentifiable client when neither is known.
+ /// - Parameters:
+ /// - request: the incoming request.
+ /// - context: the context the request is resolved against.
+ /// - Returns: the client key the request is counted under.
+ func client(
+ for request: Request,
+ context: Context
+ ) -> String {
+ if
+ configuration.trustForwardedFor,
+ let forwarded = request.headers[.xForwardedFor]?
+ .split(separator: ",")
+ .first?
+ .trimmingCharacters(in: .whitespaces),
+ !forwarded.isEmpty
+ {
+ return forwarded
+ }
+
+ if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress {
+ return address.ipAddress
+ ?? address.description
+ }
+
+ return .unidentified
+ }
+
+}
+
+// MARK: - Buckets
+
+private extension RateLimitMiddleware {
+
+ /// The outcome of asking the ``Buckets`` store to admit a request.
+ enum Admission {
+ /// The request is within the client's budget.
+ case admitted
+ /// The client exhausted its budget; the payload is the time until its window resets.
+ case limited(retryAfter: Duration)
+ }
+
+ /// The fixed-window request counters, keyed by client.
+ ///
+ /// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the
+ /// calling task never suspends — requests skip the executor hop an actor would add on every pass through the middleware.
+ final class Buckets: Sendable {
+
+ // MARK: Properties
+
+ /// The maximum number of clients tracked at once, bounding the store's memory.
+ private let capacity: Int
+
+ /// The per-client counters: the start of the client's current window and its request count.
+ private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]>
+
+ /// The number of requests admitted per client per ``window``.
+ private let limit: Int
+
+ /// The length of the fixed window the ``limit`` applies to.
+ private let window: Duration
+
+ // MARK: Initializers
+
+ /// Creates a counter store.
+ /// - Parameters:
+ /// - limit: the number of requests admitted per client per window.
+ /// - window: the length of the fixed window the limit applies to.
+ /// - capacity: the maximum number of clients tracked at once.
+ init(
+ limit: Int,
+ window: Duration,
+ capacity: Int = 10_000
+ ) {
+ self.capacity = capacity
+ self.counters = .init([:])
+ self.limit = limit
+ self.window = window
+ }
+
+ // MARK: Functions
+
+ /// Counts a request against the client's current window and admits it while the count stays within the limit.
+ /// - Parameter client: the key the request is counted under.
+ /// - Returns: the admission outcome.
+ func admit(
+ _ client: String
+ ) -> Admission {
+ let now = ContinuousClock.now
+
+ return counters.withLock { counters in
+ if let counter = counters[client], now < counter.start.advanced(by: window) {
+ guard counter.count < limit else {
+ return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window)))
+ }
+
+ counters[client] = (counter.start, counter.count + 1)
+
+ return .admitted
+ }
+
+ makeRoom(
+ in: &counters,
+ at: now
+ )
+
+ counters[client] = (now, 1)
+
+ return .admitted
+ }
+ }
+
+ // MARK: Methods
+
+ /// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the
+ /// oldest live windows are evicted in one batch — a tenth of the capacity — so the sort that finds them runs once per batch of admissions
+ /// instead of once per request while a flood of distinct clients keeps the store full.
+ /// - Parameters:
+ /// - counters: the counters the room is made in.
+ /// - now: the instant the expiry is evaluated against.
+ private func makeRoom(
+ in counters: inout [String: (start: ContinuousClock.Instant, count: Int)],
+ at now: ContinuousClock.Instant
+ ) {
+ guard counters.count >= capacity else {
+ return
+ }
+
+ counters = counters.filter {
+ now < $0.value.start.advanced(by: window)
+ }
+
+ let headroom = max(1, capacity / 10)
+ let excess = counters.count - (capacity - headroom)
+
+ guard excess > 0 else {
+ return
+ }
+
+ let oldest = counters
+ .sorted { $0.value.start < $1.value.start }
+ .prefix(excess)
+
+ for counter in oldest {
+ counters.removeValue(forKey: counter.key)
+ }
+ }
+
+ }
+
+}
+
+// MARK: - Configuration
+
+extension RateLimitMiddleware {
+ /// The limits a ``RateLimitMiddleware`` enforces.
+ public struct Configuration: Sendable {
+
+ // MARK: Properties
+
+ /// The number of requests admitted per client per ``window``.
+ public let limit: Int
+
+ /// Whether a client is keyed by the first `X-Forwarded-For` entry.
+ ///
+ /// Enable it only behind a reverse proxy that sets the header — there, the connection's own address would name the proxy for every visitor,
+ /// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys
+ /// at will.
+ public let trustForwardedFor: Bool
+
+ /// The length of the fixed window the ``limit`` applies to.
+ public let window: Duration
+
+ // MARK: Initializers
+
+ /// Creates a rate-limit configuration.
+ /// - Parameters:
+ /// - limit: the number of requests admitted per client per window.
+ /// - window: the length of the fixed window the limit applies to.
+ /// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry.
+ public init(
+ limit: Int = .RateLimit.limit,
+ window: Duration = .seconds(Int.RateLimit.window),
+ trustForwardedFor: Bool = false
+ ) {
+ self.limit = limit
+ self.trustForwardedFor = trustForwardedFor
+ self.window = window
+ }
+
+ }
+}
+
+// MARK: - String+Constants
+
+private extension String {
+ /// The bucket shared by every client the middleware cannot identify.
+ static let unidentified = "unidentified"
+}
diff --git a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift
similarity index 71%
rename from Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift
rename to Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift
index 4bd48e9..8658e8c 100644
--- a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift
+++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift
@@ -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 landing page, the ``ErrorPage`` 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 {
// MARK: Properties
@@ -20,9 +19,8 @@ public struct SecurityHeadersMiddleware {
// 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,23 +35,35 @@ 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.
///
- /// 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.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
- /// - Throws: any error thrown downstream.
+ /// - Throws: any downstream error that does not render as an HTTP response.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
- var response = try await next(request, context)
+ var response: Response
+
+ do {
+ response = try await next(
+ request,
+ context
+ )
+ } catch let error as any HTTPResponseError {
+ response = try error.response(
+ from: request,
+ context: context
+ )
+ }
for field in fields {
response.headers[field.name] = field.value
@@ -91,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
@@ -116,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.
diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift
new file mode 100644
index 0000000..a500a1b
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift
@@ -0,0 +1,71 @@
+import Foundation
+import HTTPTypes
+import Hummingbird
+
+/// Appends header names to the `Vary` header of every response passing through.
+///
+/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered
+/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that
+/// never advertised support for the encoding.
+///
+/// Names already present on a response's `Vary` header — such as the `Accept-Language` the localized pages carry — are kept, and duplicates are not
+/// added.
+public struct VaryMiddleware: Sendable {
+
+ // MARK: Properties
+
+ /// The header names appended to every response's `Vary` header.
+ private let names: [String]
+
+ // MARK: Initializers
+
+ /// Creates a vary middleware.
+ /// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header
+ /// the response-compression middleware acts on.
+ public init(
+ fields: [HTTPField.Name] = [.acceptEncoding]
+ ) {
+ self.names = fields.map(\.rawName)
+ }
+
+}
+
+// MARK: - RouterMiddleware
+
+extension VaryMiddleware: RouterMiddleware {
+
+ // MARK: Functions
+
+ /// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up.
+ /// - Parameters:
+ /// - request: the incoming request.
+ /// - context: the context the request is resolved against.
+ /// - next: the next responder in the middleware chain.
+ /// - Returns: the downstream response with the `Vary` names applied.
+ /// - Throws: any error thrown downstream.
+ public func handle(
+ _ request: Request,
+ context: Context,
+ next: (Request, Context) async throws -> Response
+ ) async throws -> Response {
+ var response = try await next(
+ request,
+ context
+ )
+
+ var vary = response.headers[.vary]?
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) } ?? []
+
+ for name in names where !vary.contains(where: {
+ $0.caseInsensitiveCompare(name) == .orderedSame
+ }) {
+ vary.append(name)
+ }
+
+ response.headers[.vary] = vary.joined(separator: ", ")
+
+ return response
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift
new file mode 100644
index 0000000..be5506d
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift
@@ -0,0 +1,78 @@
+/// 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.
+public protocol Asset: Sendable {
+
+ // MARK: Properties
+
+ /// The file extensions the asset is available with.
+ var fileExtensions: [AssetExtension] { get }
+
+ /// The asset's file name, without extension.
+ var fileName: String { get }
+
+}
+
+// MARK: - Implementations
+
+public extension Asset {
+
+ // MARK: Methods
+
+ /// Resolves the asset's path against the given base directory.
+ ///
+ /// - Parameters:
+ /// - basePath: the directory the static files are served from.
+ /// - fileExtension: the extension of the file to resolve.
+ /// - Returns: the path to the file, relative to the `basePath` path.
+ func path(
+ relativeTo basePath: String,
+ for fileExtension: AssetExtension
+ ) -> String {
+ let relativePath = relativePath(for: fileExtension)
+
+ guard !basePath.isEmpty else {
+ return relativePath
+ }
+
+ return "\(basePath)/\(relativePath)"
+ }
+
+ /// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`).
+ ///
+ /// This also matches the URL path the file is served at by `FileMiddleware`.
+ ///
+ /// - Parameter fileExtension: the extension of the file to resolve.
+ /// - Returns: the path to the file, relative to the static files root.
+ func relativePath(
+ for fileExtension: AssetExtension
+ ) -> String {
+ let file = "\(fileName).\(fileExtension.rawValue)"
+
+ return fileExtension.subdirectory
+ .map { "\($0)/\(file)" } ?? file
+ }
+
+ /// 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.
+ /// - Parameters:
+ /// - fileExtension: the extension of the file to resolve.
+ /// - version: the version token to append, or `nil` to leave the URL unversioned.
+ /// - Returns: the path to use in `href` and `src` attributes.
+ func urlPath(
+ for fileExtension: AssetExtension,
+ version: String? = nil
+ ) -> String {
+ let path = "/\(relativePath(for: fileExtension))"
+
+ guard let version, !version.isEmpty else {
+ return path
+ }
+
+ return "\(path)?v=\(version)"
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift
new file mode 100644
index 0000000..50dcb35
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift
@@ -0,0 +1,14 @@
+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.
+public protocol LocalizedRequestContext: RequestContext {
+
+ // MARK: Properties
+
+ /// The language identifier negotiated for the request.
+ var language: String { get set }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
new file mode 100644
index 0000000..30c25e1
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
@@ -0,0 +1,158 @@
+import Elementary
+import Foundation
+
+/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
+///
+/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
+/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
+/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
+public protocol Page: HTMLDocument, Sendable {
+
+ // MARK: Associated types
+
+ /// The type of the page's markup.
+ associatedtype Content: HTML
+
+ /// The type of the page's head metadata markup.
+ associatedtype Metadata: HTML
+
+ // MARK: Properties
+
+ /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
+ var assetVersion: String? { get }
+
+ /// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
+ var canonicalURL: String? { get }
+
+ /// The page's markup, rendered before the ``scripts``.
+ @HTMLBuilder
+ var content: Content { get }
+
+ /// The locale the page content is localized to.
+ var locale: Locale { get }
+
+ /// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
+ @HTMLBuilder
+ var metadata: Metadata { get }
+
+ /// The scripts loaded at the end of the document body, in order.
+ var scripts: [any Asset] { get }
+
+ /// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
+ /// to omit them.
+ var socialCard: SocialCard? { get }
+
+ /// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
+ var structuredData: StructuredData? { get }
+
+ /// The stylesheets linked in the document head, in order.
+ var stylesheets: [any Asset] { get }
+
+ /// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag.
+ var summary: String? { get }
+
+}
+
+// MARK: - Implementations
+
+public extension Page {
+
+ // MARK: Computed
+
+ /// The canonical URL is omitted unless the page provides one.
+ var canonicalURL: String? {
+ nil
+ }
+
+ /// The page ``content`` followed by its ``scripts``.
+ @HTMLBuilder
+ var body: some HTML {
+ content
+
+ for file in scripts {
+ script(.src(file.urlPath(
+ for: .js,
+ version: assetVersion
+ ))) {}
+ }
+ }
+
+ /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
+ /// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
+ ///
+ /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup,
+ /// and HTML5 allows only one.
+ ///
+ /// The structured data is an inert data block — browsers never execute it, so a site's `Content-Security-Policy` does not apply to it —
+ /// that search engines read for the organization's name, logo, and profiles.
+ @HTMLBuilder
+ var head: some HTML {
+ meta(
+ .name(.viewport),
+ .content("width=device-width, initial-scale=1")
+ )
+
+ if let summary {
+ meta(
+ .name(.description),
+ .content(summary)
+ )
+ }
+
+ if let canonicalURL {
+ link(
+ .rel("canonical"),
+ .href(canonicalURL)
+ )
+ }
+
+ if let socialCard {
+ for tag in socialCard.tags {
+ meta(
+ .custom(
+ name: tag.attribute.rawValue,
+ value: tag.name.rawValue
+ ),
+ .content(tag.content)
+ )
+ }
+ }
+
+ if let structuredData {
+ script(.custom(
+ name: "type",
+ value: "application/ld+json"
+ )) {
+ HTMLRaw(structuredData.payload)
+ }
+ }
+
+ metadata
+
+ for file in stylesheets {
+ link(
+ .rel(.stylesheet),
+ .href(file.urlPath(
+ for: .css,
+ version: assetVersion
+ ))
+ )
+ }
+ }
+
+ /// The social card is omitted unless the page provides one.
+ var socialCard: SocialCard? {
+ nil
+ }
+
+ /// The structured data is omitted unless the page provides one.
+ var structuredData: StructuredData? {
+ nil
+ }
+
+ /// The summary is omitted unless the page provides one.
+ var summary: String? {
+ nil
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift
new file mode 100644
index 0000000..ae1ca19
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift
@@ -0,0 +1,32 @@
+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(_:)``:
+///
+/// ```swift
+/// struct HealthController: RouterController {
+/// var routes: RouteCollection {
+/// RouteCollection(context: Context.self)
+/// .get("health") { _, _ in HTTPResponse.Status.ok }
+/// }
+/// }
+///
+/// router.addController {
+/// HealthController()
+/// }
+/// ```
+public protocol RouterController: Sendable {
+
+ // MARK: Associated types
+
+ /// The request context the controller's routes operate on.
+ associatedtype Context: RequestContext
+
+ // MARK: Properties
+
+ /// The collection of routes the controller exposes.
+ var routes: RouteCollection { get }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift
new file mode 100644
index 0000000..331defb
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift
@@ -0,0 +1,114 @@
+import Elementary
+import HTTPTypes
+import Hummingbird
+import NIOCore
+
+/// A pre-rendered HTTP response for a fully static HTML page.
+///
+/// The document is rendered to bytes once, at initialization, and every ``response(for:)`` reuses those bytes — along with a fixed status and
+/// precomputed headers — instead of re-rendering. This suits pages whose markup never changes between requests, such as the landing page and the
+/// not-found page, avoiding a per-request Elementary render on hot paths.
+///
+/// A successful page also revalidates cheaply: its headers carry a weak entity tag derived from the rendered bytes and a `Cache-Control` that asks
+/// clients to revalidate (`no-cache`), so a repeat visit costs a `304 Not Modified` instead of a full transfer — and a deploy that changes the page
+/// changes the tag, propagating immediately.
+///
+/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
+///
+/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the response-compression middleware downstream
+/// treats it exactly as it would a freshly rendered page.
+public struct CachedHTMLResponse: Sendable {
+
+ // MARK: Properties
+
+ /// The page rendered to bytes once.
+ private let buffer: ByteBuffer
+
+ /// The weak entity tag of the rendered bytes, present on successful pages only.
+ private let eTag: String?
+
+ /// The headers applied to every response, precomputed once.
+ private let headers: HTTPFields
+
+ /// The status applied to every response.
+ private let status: HTTPResponse.Status
+
+ // MARK: Initializers
+
+ /// Renders the given document to bytes once.
+ ///
+ /// A `200 OK` page gets the revalidation headers (`ETag` and `Cache-Control`); an error page does not, since a `304 Not Modified` only
+ /// ever stands in for a success.
+ /// - Parameters:
+ /// - status: the status applied to every response. Defaults to `.ok`.
+ /// - additionalHeaders: extra headers merged onto every response, alongside the content type.
+ /// Used to carry per-language signals such as `Content-Language` and `Vary`.
+ /// - document: the static HTML document to render and cache.
+ public init(
+ status: HTTPResponse.Status = .ok,
+ additionalHeaders: HTTPFields = [:],
+ document: some HTMLDocument
+ ) {
+ let buffer = ByteBuffer(string: document.render())
+ var headers: HTTPFields = [
+ .contentType: "text/html; charset=utf-8"
+ ]
+ var eTag: String?
+
+ if status == .ok {
+ var hash = FNV1aHash()
+
+ hash.combine(buffer.readableBytesView)
+
+ eTag = "W/\"\(hash.digest)\""
+
+ headers[.eTag] = eTag
+ headers[.cacheControl] = "public, no-cache"
+ }
+
+ for field in additionalHeaders {
+ headers[field.name] = field.value
+ }
+
+ self.buffer = buffer
+ self.eTag = eTag
+ self.headers = headers
+ self.status = status
+ }
+
+ // MARK: Methods
+
+ /// Builds a response from the cached, pre-rendered bytes.
+ ///
+ /// A conditional request whose `If-None-Match` names the page's entity tag is answered with a
+ /// bodyless `304 Not Modified`. Otherwise the full page is served, mirroring the
+ /// `text/html; charset=utf-8` content type `HTMLResponse` produces and leaving the
+ /// `Content-Length` unset so small pages remain eligible for compression.
+ /// - Parameter request: the request the response answers.
+ /// - Returns: the response carrying the cached HTML body, or its `304` revalidation.
+ public func response(
+ for request: Request
+ ) -> Response {
+ if
+ let eTag,
+ request.method == .get || request.method == .head,
+ let match = request.headers[.ifNoneMatch],
+ match == "*" || match.contains(eTag)
+ {
+ return Response(
+ status: .notModified,
+ headers: headers
+ )
+ }
+
+ return Response(
+ status: status,
+ headers: headers,
+ body: .init { [buffer] writer in
+ try await writer.write(buffer)
+ try await writer.finish(nil)
+ }
+ )
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift
similarity index 54%
rename from Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift
rename to Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift
index e6772d1..b497105 100644
--- a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift
+++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift
@@ -6,15 +6,14 @@ 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.
-struct LocalizedHTMLCollectionResponse: Sendable {
+/// 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
- /// The supported languages and default language, derived from the module's String Catalog.
+ /// The supported languages and default language, derived from the bundle's String Catalog.
private let list: LanguageList
/// The pre-rendered responses, keyed by language identifier.
@@ -24,13 +23,15 @@ struct LocalizedHTMLCollectionResponse: Sendable {
/// Renders the document once per supported language.
/// - Parameters:
+ /// - bundle: the bundle whose String Catalog names the languages the document is rendered for.
/// - status: the status applied to every response. Defaults to `.ok`.
/// - document: builds the document to render for a given locale.
- init(
+ public init(
+ bundle: Bundle,
status: HTTPResponse.Status = .ok,
document: (Locale) -> Document
) {
- self.list = .init(bundle: .module)
+ self.list = .init(bundle: bundle)
self.responses = list.all
.reduce(into: [:]) { responses, language in
responses[language] = CachedHTMLResponse(
@@ -39,7 +40,9 @@ struct LocalizedHTMLCollectionResponse: Sendable {
.contentLanguage: language,
.vary: "Accept-Language",
],
- document: document(.init(identifier: language))
+ document: document(.init(
+ identifier: language
+ ))
)
}
}
@@ -47,19 +50,26 @@ struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Methods
/// Builds the response for the given language, falling back to the default language.
- /// - Parameter language: the negotiated language identifier.
- /// - 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.
- func response(
- for language: String
+ /// - 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.
+ public func response(
+ for language: String,
+ request: Request
) -> Response {
guard
let response = responses[language] ?? responses[list.default]
else {
- return .init(status: .internalServerError)
+ return .init(
+ status: .internalServerError
+ )
}
- return response.response()
+ return response.response(
+ for: request
+ )
}
}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift
new file mode 100644
index 0000000..0fe4a74
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift
@@ -0,0 +1,102 @@
+/// The content of a page's link-preview card, rendered as Open Graph and Twitter meta tags in the document head.
+///
+/// The card carries the facts a link scraper reads — the page's title, summary, URL, and share image — and derives the meta ``tags`` expressing
+/// them. Scrapers require absolute URLs, so the card takes ``url`` and ``Image/url`` fully formed; composing them from an origin and a versioned
+/// asset path stays with the page providing the card.
+public struct SocialCard: Sendable {
+
+ // MARK: Properties
+
+ /// The card's share image, or `nil` to omit its tags.
+ public let image: Image?
+
+ /// The locale of the card's text, or `nil` to omit its tag.
+ ///
+ /// Open Graph specifies the `language_TERRITORY` form (e.g. `en_US`); scrapers also accept a bare language code (e.g. `en`).
+ public let locale: String?
+
+ /// The name of the site the card belongs to, or `nil` to omit its tag.
+ public let siteName: String?
+
+ /// The layout a Twitter card scraper gives the card.
+ public let style: Style
+
+ /// The card's summary, or `nil` to omit its tag.
+ public let summary: String?
+
+ /// The card's title.
+ public let title: String
+
+ /// The Open Graph type of the object the card describes.
+ public let type: String
+
+ /// The absolute URL the card's page is served at, or `nil` to omit its tag.
+ public let url: String?
+
+ // MARK: Initializers
+
+ /// Creates a link-preview card.
+ /// - Parameters:
+ /// - title: the card's title.
+ /// - summary: the card's summary, or `nil` (the default) to omit its tag.
+ /// - url: the absolute URL the card's page is served at, or `nil` (the default) to omit its tag.
+ /// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag.
+ /// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default)
+ /// to omit its tag.
+ /// - image: the card's share image, or `nil` (the default) to omit its tags.
+ /// - type: the Open Graph type of the object the card describes. Defaults to `website`.
+ /// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``.
+ public init(
+ title: String,
+ summary: String? = nil,
+ url: String? = nil,
+ siteName: String? = nil,
+ locale: String? = nil,
+ image: Image? = nil,
+ type: String = "website",
+ style: Style = .summaryLargeImage
+ ) {
+ self.image = image
+ self.locale = locale
+ self.siteName = siteName
+ self.style = style
+ self.summary = summary
+ self.title = title
+ self.type = type
+ self.url = url
+ }
+
+ // MARK: Computed
+
+ /// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and
+ /// the Twitter card style last. A tag whose fact the card does not carry is left out.
+ public var tags: [Tag] {
+ let tags: [Tag?] = [
+ Tag(type, name: .type),
+ siteName.map { Tag($0, name: .siteName) },
+ Tag(title, name: .title),
+ summary.map { Tag($0, name: .description) },
+ url.map { Tag($0, name: .url) },
+ locale.map { Tag($0, name: .locale) },
+ ] + (image?.tags ?? []) + [
+ Tag(style.rawValue, name: .twitter),
+ ]
+
+ return tags.compactMap { $0 }
+ }
+
+}
+
+// MARK: - Enumerations
+
+public extension SocialCard {
+
+ /// The layout a Twitter card scraper gives a ``SocialCard``.
+ enum Style: String, Sendable {
+ /// A compact card with a small thumbnail.
+ case summary
+ /// A card with a large image above the text.
+ case summaryLargeImage = "summary_large_image"
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift
new file mode 100644
index 0000000..43f8575
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift
@@ -0,0 +1,54 @@
+extension SocialCard {
+ /// The share image of a ``SocialCard``.
+ public struct Image: Sendable {
+
+ // MARK: Properties
+
+ /// The image's text for assistive technologies, or `nil` to omit it.
+ public let alt: String?
+
+ /// The image's height in pixels, letting scrapers lay the card out before fetching the image.
+ public let height: Int
+
+ /// The absolute URL the image is served at.
+ public let url: String
+
+ /// The image's width in pixels, letting scrapers lay the card out before fetching the image.
+ public let width: Int
+
+ // MARK: Initializers
+
+ /// Creates a share image.
+ /// - Parameters:
+ /// - url: the absolute URL the image is served at.
+ /// - width: the image's width in pixels.
+ /// - height: the image's height in pixels.
+ /// - alt: the image's text for assistive technologies, or `nil` (the default) to omit it.
+ public init(
+ url: String,
+ width: Int,
+ height: Int,
+ alt: String? = nil
+ ) {
+ self.alt = alt
+ self.height = height
+ self.url = url
+ self.width = width
+ }
+
+ // MARK: Computed
+
+ /// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one.
+ public var tags: [Tag] {
+ let tags: [Tag?] = [
+ Tag(url, name: .image),
+ Tag(String(width), name: .imageWidth),
+ Tag(String(height), name: .imageHeight),
+ alt.map { Tag($0, name: .imageAlt) },
+ ]
+
+ return tags.compactMap { $0 }
+ }
+
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift
new file mode 100644
index 0000000..ec446a5
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift
@@ -0,0 +1,88 @@
+extension SocialCard {
+ /// A head meta tag of a ``SocialCard``: its name and content, keyed by the attribute its ``name`` dictates.
+ public struct Tag: Equatable, Sendable {
+
+ // MARK: Properties
+
+ /// The tag's value.
+ public let content: String
+
+ /// The tag's name.
+ public let name: Name
+
+ // MARK: Initializers
+
+ /// Creates a head meta tag.
+ /// - Parameters:
+ /// - content: the tag's value.
+ /// - name: the tag's name, dictating the attribute the tag is keyed by.
+ public init(
+ _ content: String,
+ name: Name
+ ) {
+ self.content = content
+ self.name = name
+ }
+
+ // MARK: Computed
+
+ /// The attribute the tag is keyed by, dictated by its ``name``.
+ public var attribute: Attribute {
+ name.attribute
+ }
+
+ }
+}
+
+// MARK: - Enumerations
+
+extension SocialCard.Tag {
+
+ /// The meta attribute a ``SocialCard/Tag`` is keyed by, named by its raw value.
+ public enum Attribute: String, Sendable {
+ /// The `name` attribute, keying the Twitter tags.
+ case name
+ /// The `property` attribute, keying the Open Graph tags.
+ case property
+ }
+
+ /// The name of a ``SocialCard/Tag``, carried in its raw value.
+ public enum Name: String, Sendable {
+ /// The `og:description` tag, carrying the card's summary.
+ case description = "og:description"
+ /// The `og:image` tag, carrying the share image's absolute URL.
+ case image = "og:image"
+ /// The `og:image:alt` tag, carrying the share image's text for assistive technologies.
+ case imageAlt = "og:image:alt"
+ /// The `og:image:height` tag, carrying the share image's height in pixels.
+ case imageHeight = "og:image:height"
+ /// The `og:image:width` tag, carrying the share image's width in pixels.
+ case imageWidth = "og:image:width"
+ /// The `og:locale` tag, carrying the locale of the card's text.
+ case locale = "og:locale"
+ /// The `og:site_name` tag, carrying the name of the site the card belongs to.
+ case siteName = "og:site_name"
+ /// The `og:title` tag, carrying the card's title.
+ case title = "og:title"
+ /// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
+ case twitter = "twitter:card"
+ /// The `og:type` tag, carrying the Open Graph type of the object the card describes.
+ case type = "og:type"
+ /// The `og:url` tag, carrying the absolute URL the card's page is served at.
+ case url = "og:url"
+ }
+
+}
+
+// MARK: - Implementations
+
+public extension SocialCard.Tag.Name {
+
+ // MARK: Computed
+
+ /// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
+ var attribute: SocialCard.Tag.Attribute {
+ self == .twitter ? .name : .property
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift
new file mode 100644
index 0000000..5e5addb
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift
@@ -0,0 +1,106 @@
+/// The structured data of a page, rendered as a JSON-LD script in the document head.
+///
+/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
+/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
+/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
+///
+/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
+/// page providing the data.
+public struct StructuredData: Equatable, Sendable {
+
+ // MARK: Properties
+
+ /// The schema.org nodes of the data's graph, in the order they render.
+ public let nodes: [Node]
+
+ // MARK: Initializers
+
+ /// Creates structured data from the nodes of its graph.
+ /// - Parameter nodes: the schema.org nodes of the data's graph, in the order they render.
+ public init(
+ nodes: [Node]
+ ) {
+ self.nodes = nodes
+ }
+
+ // MARK: Computed
+
+ /// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
+ ///
+ /// The values are rendered as JSON string literals with `<` escaped as well, so a value can never close the `script` tag embedding
+ /// the payload.
+ public var payload: String {
+ #"{"@context":"https://schema.org","@graph":[\#(fragments)]}"#
+ }
+
+}
+
+// MARK: - Initializers
+
+public extension StructuredData {
+
+ /// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
+ /// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
+ /// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
+ /// A property whose fact the data does not carry is left out.
+ /// - Parameters:
+ /// - name: the name of the organization and the site.
+ /// - url: the absolute URL the site is served at.
+ /// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
+ /// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
+ init(
+ name: String,
+ url: String,
+ logo: String? = nil,
+ profiles: [String] = []
+ ) {
+ let id = url + "#organization"
+
+ var organization: [Property] = [
+ .init(.name, value: .string(name)),
+ .init(.url, value: .string(url)),
+ ]
+
+ if let logo {
+ organization.append(.init(.logo, value:.string(logo)))
+ }
+
+ if !profiles.isEmpty {
+ organization.append(.init(
+ .sameAs,
+ value: .array(profiles.map(Value.string))
+ ))
+ }
+
+ self.init(nodes: [
+ .init(
+ type: .organization,
+ id: id,
+ properties: organization
+ ),
+ .init(
+ type: .website,
+ properties: [
+ .init(.name, value: .string(name)),
+ .init(.url, value: .string(url)),
+ .init(.publisher, value: .reference(id)),
+ ]
+ ),
+ ])
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension StructuredData {
+
+ // MARK: Computed
+
+ var fragments: String {
+ nodes
+ .map(\.fragment)
+ .joined(separator: .Separator.comma)
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift
new file mode 100644
index 0000000..6ba19af
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift
@@ -0,0 +1,92 @@
+extension StructuredData {
+ /// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
+ public struct Node: Equatable, Sendable {
+
+ // MARK: Properties
+
+ /// The node's identifier, rendered as its `@id` property, or `nil` to omit it.
+ ///
+ /// Another node references this node through ``Value/reference(_:)`` with the same identifier.
+ public let id: String?
+
+ /// The node's properties, in the order they render after the type and identifier.
+ public let properties: [Property]
+
+ /// The node's schema.org type (e.g. ``Kind/organization``), rendered as its `@type` property.
+ public let type: Kind
+
+ // MARK: Initializers
+
+ /// Creates a node.
+ /// - Parameters:
+ /// - type: the node's schema.org type (e.g. ``Kind/organization``).
+ /// - id: the node's identifier, or `nil` (the default) to omit it.
+ /// - properties: the node's properties, in the order they render.
+ public init(
+ type: Kind,
+ id: String? = nil,
+ properties: [Property]
+ ) {
+ self.id = id
+ self.properties = properties
+ self.type = type
+ }
+
+ // MARK: Computed
+
+ /// The node's minified JSON object: the `@type`, the `@id` (when the node carries one), and the ``properties`` in order.
+ var fragment: String {
+ var members = [#""@type":\#(Value.literal(type.rawValue))"#]
+
+ if let id {
+ members.append(#""@id":\#(Value.literal(id))"#)
+ }
+
+ members += properties.map(\.fragment)
+
+ return "{\(members.joined(separator: .Separator.comma))}"
+ }
+
+ }
+}
+
+// MARK: - Structures
+
+extension StructuredData.Node {
+ /// The schema.org type of a ``StructuredData/Node``.
+ ///
+ /// Schema.org's vocabulary is open, so the kind is a typed string rather than a closed enumeration: the kinds every service shares
+ /// come as constants, a service declares the kinds its own node shapes need in an extension, and a one-off kind can be spelled as a
+ /// string literal.
+ public struct Kind: Equatable, ExpressibleByStringLiteral, Sendable {
+
+ // MARK: Properties
+
+ /// The type as it renders in the payload.
+ public let rawValue: String
+
+ // MARK: Initializers
+
+ /// Creates a kind.
+ /// - Parameter rawValue: the type as it renders in the payload.
+ public init(_ rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ /// Creates a kind from a string literal.
+ /// - Parameter value: the type as it renders in the payload.
+ public init(stringLiteral value: String) {
+ self.init(value)
+ }
+
+ }
+}
+
+// MARK: - Constants
+
+public extension StructuredData.Node.Kind {
+ /// An organization, e.g. the one publishing a website.
+ static let organization: Self = "Organization"
+ /// A website.
+ static let website: Self = "WebSite"
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift
new file mode 100644
index 0000000..fb52cb0
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift
@@ -0,0 +1,82 @@
+extension StructuredData {
+ /// A named property of a ``Node``, in the position it renders.
+ public struct Property: Equatable, Sendable {
+
+ // MARK: Properties
+
+ /// The property's schema.org name (e.g. `sameAs`).
+ public let name: Name
+
+ /// The property's value.
+ public let value: Value
+
+ // MARK: Initializers
+
+ /// Creates a property.
+ /// - Parameters:
+ /// - name: the property's schema.org name (e.g. `sameAs`).
+ /// - value: the property's value.
+ public init(
+ _ name: Name,
+ value: Value
+ ) {
+ self.name = name
+ self.value = value
+ }
+
+ // MARK: Computed
+
+ /// The property's minified JSON member: its name and its rendered value.
+ var fragment: String {
+ #"\#(Value.literal(name.rawValue)):\#(value.fragment)"#
+ }
+
+ }
+}
+
+// MARK: - Structures
+
+extension StructuredData.Property {
+ /// The schema.org name of a ``StructuredData/Property``.
+ ///
+ /// Schema.org's vocabulary is open, so the name is a typed string rather than a closed enumeration: the names every service shares
+ /// come as constants, a service declares the names its own node shapes need in an extension, and a one-off name can be spelled as a
+ /// string literal.
+ public struct Name: Equatable, ExpressibleByStringLiteral, Sendable {
+
+ // MARK: Properties
+
+ /// The name as it renders in the payload.
+ public let rawValue: String
+
+ // MARK: Initializers
+
+ /// Creates a name.
+ /// - Parameter rawValue: the name as it renders in the payload.
+ public init(_ rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ /// Creates a name from a string literal.
+ /// - Parameter value: the name as it renders in the payload.
+ public init(stringLiteral value: String) {
+ self.init(value)
+ }
+
+ }
+}
+
+// MARK: - Constants
+
+public extension StructuredData.Property.Name {
+ /// The absolute URL of an organization's logo.
+ static let logo: Self = "logo"
+ /// The name of the thing a node describes.
+ static let name: Self = "name"
+ /// The organization publishing a website.
+ static let publisher: Self = "publisher"
+ /// The absolute URLs of the profiles that also identify the thing a node describes.
+ static let sameAs: Self = "sameAs"
+ /// The absolute URL of the thing a node describes.
+ static let url: Self = "url"
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift
new file mode 100644
index 0000000..4ee5324
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift
@@ -0,0 +1,67 @@
+extension StructuredData {
+ /// A value of a ``Property``: a string, a list, a nested node, or a reference to another node.
+ ///
+ /// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script`
+ /// tag embedding the payload it renders into.
+ public indirect enum Value: Equatable, Sendable {
+ /// A list of values.
+ case array([Value])
+ /// A nested node, e.g. the place a schema.org event is located at.
+ case node(Node)
+ /// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
+ case reference(String)
+ /// A string value.
+ case string(String)
+ }
+}
+
+// MARK: - Extensions
+
+extension StructuredData.Value {
+
+ // MARK: Computed
+
+ /// The value's minified JSON fragment.
+ var fragment: String {
+ switch self {
+ case .array(let values):
+ "[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
+ case .node(let node):
+ node.fragment
+ case .reference(let id):
+ #"{"@id":\#(Self.literal(id))}"#
+ case .string(let string):
+ Self.literal(string)
+ }
+ }
+
+ // MARK: Methods
+
+ /// Renders a string as a JSON string literal, escaping `<` as well since the payload is embedded in a `script` tag the string
+ /// could otherwise close.
+ /// - Parameter value: the string to render.
+ /// - Returns: the quoted and escaped literal.
+ static func literal(_ value: String) -> String {
+ var literal = "\""
+
+ for scalar in value.unicodeScalars {
+ switch scalar {
+ case "\"":
+ literal += #"\""#
+ case "\\":
+ literal += #"\\"#
+ case "<":
+ literal += #"\u003c"#
+ case let scalar where scalar.value < 0x20:
+ let hex = String(scalar.value, radix: 16)
+
+ literal += #"\u"# + String(repeating: "0", count: 4 - hex.count) + hex
+ default:
+ literal.unicodeScalars.append(scalar)
+ }
+ }
+
+ return literal + "\""
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift
new file mode 100644
index 0000000..8931989
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift
@@ -0,0 +1,79 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "FNV1aHash type",
+ .tags(.type)
+)
+struct FNV1aHashTests {
+
+ // MARK: Functional tests
+
+ @Test(arguments: [
+ ("", "cbf29ce484222325"),
+ ("a", "af63dc4c8601ec8c"),
+ ("b", "af63df4c8601f1a5"),
+ ("foobar", "85944171f73967e8"),
+ ])
+ func `matches the published FNV-1a 64-bit test vectors`(
+ input: String,
+ digest: String
+ ) {
+ var hash = FNV1aHash()
+
+ hash.combine(input.utf8)
+
+ #expect(hash.digest == digest)
+ }
+
+ @Test
+ func `pads the digest to sixteen characters`() {
+ var hash = FNV1aHash()
+
+ // "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep.
+ hash.combine("aa".utf8)
+
+ #expect(hash.digest == "089c4307b54596b7")
+ #expect(hash.digest.count == 16)
+ }
+
+ @Test
+ func `hashes incrementally combined bytes as one stream`() {
+ var combined = FNV1aHash()
+ var whole = FNV1aHash()
+
+ combined.combine("foo".utf8)
+ combined.combine("bar".utf8)
+ whole.combine("foobar".utf8)
+
+ #expect(combined.digest == whole.digest)
+ }
+
+ @Test
+ func `distinguishes the order of the combined bytes`() {
+ var forward = FNV1aHash()
+ var backward = FNV1aHash()
+
+ forward.combine("ab".utf8)
+ backward.combine("ba".utf8)
+
+ #expect(forward.digest != backward.digest)
+ }
+
+ @Test
+ func `digests without consuming the running hash`() {
+ var hash = FNV1aHash()
+
+ hash.combine("foo".utf8)
+
+ let first = hash.digest
+
+ #expect(hash.digest == first)
+
+ hash.combine("bar".utf8)
+
+ #expect(hash.digest != first)
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift
new file mode 100644
index 0000000..f49dbea
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift
@@ -0,0 +1,74 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "AssetExtension enumeration",
+ .tags(.asset)
+)
+struct AssetExtensionTests {
+
+ // MARK: Computed tests
+
+ @Test(arguments: zip(
+ Self.extensions,
+ Self.contentTypes
+ ))
+ func `content type`(
+ for fileExtension: AssetExtension,
+ expects contentType: String
+ ) {
+ #expect(fileExtension.contentType == contentType)
+ }
+
+ @Test(arguments: zip(
+ Self.extensions,
+ Self.subdirectories
+ ))
+ func `subdirectory`(
+ for fileExtension: AssetExtension,
+ expects subdirectory: String?
+ ) {
+ #expect(fileExtension.subdirectory == subdirectory)
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension AssetExtensionTests {
+
+ // MARK: Constants
+
+ static let extensions: [AssetExtension] = [
+ .css,
+ .js,
+ .png,
+ .ico,
+ .svg,
+ .txt,
+ .webmanifest,
+ .xml
+ ]
+ static let contentTypes: [String] = [
+ "text/css",
+ "text/javascript",
+ "image/png",
+ "image/vnd.microsoft.icon",
+ "image/svg+xml",
+ "text/plain",
+ "application/manifest+json",
+ "application/xml"
+ ]
+ static let subdirectories: [String?] = [
+ "css",
+ "js",
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil
+ ]
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift
new file mode 100644
index 0000000..8deb485
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift
@@ -0,0 +1,159 @@
+import Hummingbird
+import HummingbirdTesting
+import NIOCore
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "addController method",
+ .tags(.`extension`)
+)
+struct RouterMethodsTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `adds the routes of every listed controller`() async throws {
+ let router = Router()
+
+ router.addController {
+ StubController(path: "first")
+ StubController(path: "second")
+ }
+
+ try await Application(router: router).test(.router) { client in
+ try await client.execute(
+ uri: "/first",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(String(buffer: response.body) == "first")
+ }
+
+ try await client.execute(
+ uri: "/second",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(String(buffer: response.body) == "second")
+ }
+ }
+ }
+
+ @Test(arguments: [true, false])
+ func `adds a controller behind a condition only when the condition holds`(
+ condition: Bool
+ ) async throws {
+ let router = Router()
+
+ router.addController {
+ StubController(path: "always")
+
+ if condition {
+ StubController(path: "conditional")
+ }
+ }
+
+ try await Application(router: router).test(.router) { client in
+ try await client.execute(
+ uri: "/always",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+
+ try await client.execute(
+ uri: "/conditional",
+ method: .get
+ ) { response in
+ #expect(response.status == (condition ? .ok : .notFound))
+ }
+ }
+ }
+
+ @Test(arguments: [true, false])
+ func `adds only the taken branch of a condition`(
+ takesFirst: Bool
+ ) async throws {
+ let router = Router()
+
+ router.addController {
+ if takesFirst {
+ StubController(path: "first")
+ } else {
+ StubController(path: "second")
+ }
+ }
+
+ try await Application(router: router).test(.router) { client in
+ try await client.execute(
+ uri: "/first",
+ method: .get
+ ) { response in
+ #expect(response.status == (takesFirst ? .ok : .notFound))
+ }
+
+ try await client.execute(
+ uri: "/second",
+ method: .get
+ ) { response in
+ #expect(response.status == (takesFirst ? .notFound : .ok))
+ }
+ }
+ }
+
+ @Test
+ func `adds a controller for every iteration of a loop`() async throws {
+ let paths = ["one", "two", "three"]
+ let router = Router()
+
+ router.addController {
+ for path in paths {
+ StubController(path: path)
+ }
+ }
+
+ try await Application(router: router).test(.router) { client in
+ for path in paths {
+ try await client.execute(
+ uri: "/\(path)",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(String(buffer: response.body) == path)
+ }
+ }
+ }
+ }
+
+ @Test
+ func `returns the router so calls can be chained`() async throws {
+ let router = Router()
+
+ router
+ .addController {
+ StubController(path: "first")
+ }
+ .addController {
+ StubController(path: "second")
+ }
+
+ try await Application(router: router).test(.router) { client in
+ try await client.execute(
+ uri: "/first",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+
+ try await client.execute(
+ uri: "/second",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ }
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift
new file mode 100644
index 0000000..cb8d213
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift
@@ -0,0 +1,174 @@
+import Foundation
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "FingerprintAssets method",
+ .tags(.asset)
+)
+struct FingerprintAssetsTests {
+
+ // MARK: Properties
+
+ private let fingerprint = FingerprintAssets()
+
+ // MARK: Functional tests
+
+ @Test
+ func `fingerprints the files under a directory`() throws {
+ let directory = try makeDirectory(files: [
+ "css/site.css": "body { margin: 0; }",
+ "robots.txt": "User-agent: *"
+ ])
+
+ defer {
+ removeDirectory(directory)
+ }
+
+ let token = try #require(fingerprint(directory.path))
+
+ #expect(token.count == 16)
+ #expect(token.allSatisfy { $0.isHexDigit })
+ }
+
+ @Test
+ func `agrees across directories with identical contents`() throws {
+ let files = [
+ "css/site.css": "body { margin: 0; }",
+ "js/site.js": "console.log(1);"
+ ]
+ let first = try makeDirectory(files: files)
+ let second = try makeDirectory(files: files)
+
+ defer {
+ removeDirectory(first)
+ removeDirectory(second)
+ }
+
+ #expect(fingerprint(first.path) == fingerprint(second.path))
+ }
+
+ @Test
+ func `changes the token when a file's contents change`() throws {
+ let directory = try makeDirectory(files: [
+ "css/site.css": "body { margin: 0; }"
+ ])
+
+ defer {
+ removeDirectory(directory)
+ }
+
+ let before = fingerprint(directory.path)
+
+ try "body { margin: 1px; }".write(
+ to: directory.appendingPathComponent("css/site.css"),
+ atomically: true,
+ encoding: .utf8
+ )
+
+ #expect(fingerprint(directory.path) != before)
+ }
+
+ @Test
+ func `changes the token when a file is renamed`() throws {
+ let contents = "body { margin: 0; }"
+ let first = try makeDirectory(files: ["css/site.css": contents])
+ let second = try makeDirectory(files: ["css/main.css": contents])
+
+ defer {
+ removeDirectory(first)
+ removeDirectory(second)
+ }
+
+ #expect(fingerprint(first.path) != fingerprint(second.path))
+ }
+
+ @Test
+ func `changes the token when a file is added`() throws {
+ let directory = try makeDirectory(files: [
+ "css/site.css": "body { margin: 0; }"
+ ])
+
+ defer {
+ removeDirectory(directory)
+ }
+
+ let before = fingerprint(directory.path)
+
+ try "console.log(1);".write(
+ to: directory.appendingPathComponent("site.js"),
+ atomically: true,
+ encoding: .utf8
+ )
+
+ #expect(fingerprint(directory.path) != before)
+ }
+
+ @Test
+ func `returns nil for a directory without files`() throws {
+ let directory = try makeDirectory(files: [:])
+
+ defer {
+ removeDirectory(directory)
+ }
+
+ #expect(fingerprint(directory.path) == nil)
+ }
+
+ @Test
+ func `returns nil for a missing directory`() {
+ let missing = FileManager.default.temporaryDirectory
+ .appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)")
+
+ #expect(fingerprint(missing.path) == nil)
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension FingerprintAssetsTests {
+
+ // MARK: Methods
+
+ /// Creates a unique temporary directory holding the given files, keyed by relative path.
+ /// - Parameter files: the files to create, keyed by their path relative to the directory.
+ /// - Returns: the URL of the created directory.
+ func makeDirectory(
+ files: [String: String]
+ ) throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)")
+
+ try FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+
+ for (relativePath, contents) in files {
+ let file = directory.appendingPathComponent(relativePath)
+
+ try FileManager.default.createDirectory(
+ at: file.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try contents.write(
+ to: file,
+ atomically: true,
+ encoding: .utf8
+ )
+ }
+
+ return directory
+ }
+
+ /// Removes a temporary directory created by ``makeDirectory(files:)``.
+ /// - Parameter directory: the URL of the directory to remove.
+ func removeDirectory(
+ _ directory: URL
+ ) {
+ try? FileManager.default.removeItem(at: directory)
+ }
+
+}
diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift
similarity index 60%
rename from Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift
rename to Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift
index 607bc3a..f246f5c 100644
--- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift
+++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift
@@ -1,21 +1,25 @@
+import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdTesting
import NIOCore
import Testing
-@testable import WebsiteLibrary
+@testable import Infrastructure
-@Suite("LocalizationMiddleware middleware")
+@Suite(
+ "LocalizationMiddleware middleware",
+ .tags(.middleware)
+)
struct LocalizationMiddlewareTests {
// MARK: Constants
private let app: Application = .init(router: {
- let router = Router(context: WebsiteRequestContext.self)
+ let router = Router(context: StubRequestContext.self)
router.addMiddleware {
- LocalizationMiddleware()
+ LocalizationMiddleware(bundle: .module)
}
router.get("language") { _, context in
@@ -33,9 +37,9 @@ struct LocalizationMiddlewareTests {
try await client.execute(
uri: "/language",
method: .get,
- headers: [.acceptLanguage: "en-US,en;q=0.9"]
+ headers: [.acceptLanguage: "de-DE,de;q=0.9"]
) { response in
- #expect(String(buffer: response.body) == "en")
+ #expect(String(buffer: response.body) == "de")
}
}
}
@@ -52,4 +56,17 @@ struct LocalizationMiddlewareTests {
}
}
+ @Test
+ func `falls back to the default for an unsupported language`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/language",
+ method: .get,
+ headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
+ ) { response in
+ #expect(String(buffer: response.body) == "en")
+ }
+ }
+ }
+
}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift
new file mode 100644
index 0000000..ea26298
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift
@@ -0,0 +1,194 @@
+import Foundation
+import Hummingbird
+import HummingbirdTesting
+import NIOCore
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "NotFoundMiddleware middleware",
+ .tags(.middleware)
+)
+struct NotFoundMiddlewareTests {
+
+ // MARK: Constants
+
+ private let app: Application = .init(router: {
+ let router = Router(context: StubRequestContext.self)
+
+ router.addMiddleware {
+ LocalizationMiddleware(bundle: .module)
+ NotFoundMiddleware(bundle: .module) {
+ StubPage(locale: $0)
+ }
+ }
+
+ router.get("hello") { _, _ in
+ "Hello!"
+ }
+
+ router.get("boom") { _, _ -> String in
+ throw HTTPError(.badRequest)
+ }
+
+ return router
+ }())
+
+ // MARK: Functional tests
+
+ @Test
+ func `renders the error page for an unmatched request`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .notFound)
+ #expect(response.headers[.contentType] == "text/html; charset=utf-8")
+ #expect(response.headers[.contentLanguage] == "en")
+ #expect(response.headers[.vary] == "Accept-Language")
+ #expect(body.contains("Stub content"))
+ }
+ }
+ }
+
+ @Test
+ func `renders the error page in the negotiated language`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get,
+ headers: [.acceptLanguage: "de-DE,de;q=0.9"]
+ ) { response in
+ #expect(response.status == .notFound)
+ #expect(response.headers[.contentLanguage] == "de")
+ }
+ }
+ }
+
+ @Test
+ func `passes a matched response through untouched`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(response.body == ByteBuffer(string: "Hello!"))
+ }
+ }
+ }
+
+ @Test
+ func `rethrows a non-not-found error unchanged`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/boom",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .badRequest)
+ #expect(!body.contains("Stub content"))
+ }
+ }
+ }
+
+ @Test
+ func `renders versioned asset URLs when given a version`() async throws {
+ try await app(
+ assetVersion: "0123456789abcdef"
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains("/css/stub.css?v=0123456789abcdef"))
+ #expect(body.contains("/js/stub.js?v=0123456789abcdef"))
+ }
+ }
+ }
+
+ @Test
+ func `renders unversioned asset URLs by default`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains(#"href="/css/stub.css""#))
+ #expect(!body.contains("?v="))
+ }
+ }
+ }
+
+ @Test
+ func `serves the error page without revalidation headers`() async throws {
+ // A `304 Not Modified` only ever stands in for a success, so the error page must not
+ // invite revalidation with an entity tag or a cache policy.
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ #expect(response.status == .notFound)
+ #expect(response.headers[.eTag] == nil)
+ #expect(response.headers[.cacheControl] == nil)
+ }
+ }
+ }
+
+ @Test
+ func `serves the full error page to a conditional request`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get,
+ headers: [.ifNoneMatch: "*"]
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .notFound)
+ #expect(body.contains("Stub content"))
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension NotFoundMiddlewareTests {
+
+ // MARK: Methods
+
+ /// Builds an application whose not-found middleware appends the given version token to the
+ /// error page's asset URLs.
+ /// - Parameter assetVersion: the version token appended to the page's asset URLs.
+ /// - Returns: the configured application.
+ func app(
+ assetVersion: String?
+ ) -> some ApplicationProtocol {
+ let router = Router(context: StubRequestContext.self)
+
+ router.addMiddleware {
+ LocalizationMiddleware(bundle: .module)
+ NotFoundMiddleware(bundle: .module) {
+ StubPage(
+ locale: $0,
+ assetVersion: assetVersion
+ )
+ }
+ }
+
+ return Application(router: router)
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift
new file mode 100644
index 0000000..75aa6c7
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift
@@ -0,0 +1,173 @@
+import Hummingbird
+import HummingbirdTesting
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "RateLimitMiddleware middleware",
+ .tags(.middleware)
+)
+struct RateLimitMiddlewareTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `admits requests within the limit`() async throws {
+ try await app(
+ configuration: .init(limit: 3)
+ ).test(.router) { client in
+ for _ in 1 ... 3 {
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ }
+ }
+ }
+
+ @Test
+ func `rejects a request over the limit with a retry-after header`() async throws {
+ try await app(
+ configuration: .init(limit: 2)
+ ).test(.router) { client in
+ for _ in 1 ... 2 {
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ }
+
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .tooManyRequests)
+
+ let retryAfter = try #require(response.headers[.retryAfter])
+
+ #expect(try #require(Int(retryAfter)) >= 1)
+ }
+ }
+ }
+
+ @Test
+ func `admits requests again once the window resets`() async throws {
+ try await app(
+ configuration: .init(
+ limit: 1,
+ window: .milliseconds(50)
+ )
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .tooManyRequests)
+ }
+
+ try await Task.sleep(for: .milliseconds(100))
+
+ try await client.execute(
+ uri: "/hello",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ }
+ }
+
+ @Test
+ func `separates clients by their forwarded address when trusted`() async throws {
+ try await app(
+ configuration: .init(
+ limit: 1,
+ trustForwardedFor: true
+ )
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/hello",
+ method: .get,
+ headers: [.xForwardedFor: "203.0.113.7"]
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ try await client.execute(
+ uri: "/hello",
+ method: .get,
+ headers: [.xForwardedFor: "203.0.113.8"]
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ // The first entry names the client; the appended proxy hop must not change its key.
+ try await client.execute(
+ uri: "/hello",
+ method: .get,
+ headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"]
+ ) { response in
+ #expect(response.status == .tooManyRequests)
+ }
+ }
+ }
+
+ @Test
+ func `ignores the forwarded address when not trusted`() async throws {
+ try await app(
+ configuration: .init(limit: 1)
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/hello",
+ method: .get,
+ headers: [.xForwardedFor: "203.0.113.7"]
+ ) { response in
+ #expect(response.status == .ok)
+ }
+ // Without trust (and without a connection address in router-only testing), every client
+ // shares one bucket, so a rotated header must not mint a fresh budget.
+ try await client.execute(
+ uri: "/hello",
+ method: .get,
+ headers: [.xForwardedFor: "203.0.113.8"]
+ ) { response in
+ #expect(response.status == .tooManyRequests)
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension RateLimitMiddlewareTests {
+
+ // MARK: Methods
+
+ /// Builds an application whose router applies the rate-limit middleware ahead of a single
+ /// `/hello` route returning a plain body.
+ func app(
+ configuration: RateLimitMiddleware.Configuration
+ ) -> some ApplicationProtocol {
+ let router = Router()
+
+ router.addMiddleware {
+ RateLimitMiddleware(configuration: configuration)
+ }
+
+ router.get("hello") { _, _ in
+ "Hello!"
+ }
+
+ return Application(router: router)
+ }
+
+}
diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift
similarity index 69%
rename from Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift
rename to Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift
index 224f303..c15e01d 100644
--- a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift
+++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift
@@ -2,9 +2,12 @@ import Hummingbird
import HummingbirdTesting
import Testing
-@testable import WebsiteLibrary
+@testable import Infrastructure
-@Suite("SecurityHeadersMiddleware middleware")
+@Suite(
+ "SecurityHeadersMiddleware middleware",
+ .tags(.middleware)
+)
struct SecurityHeadersMiddlewareTests {
// MARK: Functional tests
@@ -17,11 +20,11 @@ struct SecurityHeadersMiddlewareTests {
method: .get
) { response in
#expect(response.status == .ok)
- #expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
- #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
- #expect(response.headers[.frameOptions] == String.Security.frameOptions)
- #expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
- #expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
+ #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
+ #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
+ #expect(response.headers[.frameOptions] == .Security.frameOptions)
+ #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
+ #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
}
}
}
@@ -91,7 +94,24 @@ struct SecurityHeadersMiddlewareTests {
uri: "/weak",
method: .get
) { response in
- #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
+ #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
+ }
+ }
+ }
+
+ @Test
+ func `applies the security headers to an error response`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/throws",
+ method: .get
+ ) { response in
+ #expect(response.status == .badRequest)
+ #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
+ #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
+ #expect(response.headers[.frameOptions] == .Security.frameOptions)
+ #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
+ #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
}
}
}
@@ -104,9 +124,10 @@ private extension SecurityHeadersMiddlewareTests {
// MARK: Methods
- /// Builds an application whose router applies the security-headers middleware ahead of two
- /// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a
- /// deliberately weak `X-Content-Type-Options` value for the middleware to override.
+ /// Builds an application whose router applies the security-headers middleware ahead of three
+ /// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a
+ /// deliberately weak `X-Content-Type-Options` value for the middleware to override, and
+ /// `/throws` fails with an `HTTPError` the way the controllers do on invalid input.
func app(
configuration: SecurityHeadersMiddleware.Configuration = .init()
) -> some ApplicationProtocol {
@@ -128,6 +149,10 @@ private extension SecurityHeadersMiddlewareTests {
return response
}
+ router.get("throws") { _, _ -> Response in
+ throw HTTPError(.badRequest)
+ }
+
return Application(router: router)
}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift
new file mode 100644
index 0000000..30389de
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift
@@ -0,0 +1,134 @@
+import HTTPTypes
+import Hummingbird
+import HummingbirdTesting
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "VaryMiddleware middleware",
+ .tags(.middleware)
+)
+struct VaryMiddlewareTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `adds the default field to a response without a vary header`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/plain",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(response.headers[.vary] == "Accept-Encoding")
+ }
+ }
+ }
+
+ @Test
+ func `appends to an existing vary header`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/localized",
+ method: .get
+ ) { response in
+ #expect(response.headers[.vary] == "Accept-Language, Accept-Encoding")
+ }
+ }
+ }
+
+ @Test
+ func `does not duplicate a name already present`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/encoded",
+ method: .get
+ ) { response in
+ #expect(response.headers[.vary] == "Accept-Encoding")
+ }
+ }
+ }
+
+ @Test
+ func `matches an existing name regardless of its casing`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/lowercased",
+ method: .get
+ ) { response in
+ #expect(response.headers[.vary] == "accept-encoding")
+ }
+ }
+ }
+
+ @Test
+ func `normalizes the whitespace of an existing list`() async throws {
+ try await app().test(.router) { client in
+ try await client.execute(
+ uri: "/spaced",
+ method: .get
+ ) { response in
+ #expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding")
+ }
+ }
+ }
+
+ @Test
+ func `appends every configured field`() async throws {
+ try await app(
+ fields: [.acceptEncoding, .acceptLanguage]
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/plain",
+ method: .get
+ ) { response in
+ #expect(response.headers[.vary] == "Accept-Encoding, Accept-Language")
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension VaryMiddlewareTests {
+
+ // MARK: Methods
+
+ /// Builds an application whose router applies the vary middleware ahead of routes whose
+ /// responses carry different `Vary` starting points: `/plain` none, `/localized` an
+ /// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase
+ /// `accept-encoding`, and `/spaced` a list with irregular whitespace.
+ func app(
+ fields: [HTTPField.Name] = [.acceptEncoding]
+ ) -> some ApplicationProtocol {
+ let router = Router()
+
+ router.addMiddleware {
+ VaryMiddleware(fields: fields)
+ }
+
+ router.get("plain") { _, _ in
+ "Hello!"
+ }
+
+ for (path, vary) in [
+ ("localized", "Accept-Language"),
+ ("encoded", "Accept-Encoding"),
+ ("lowercased", "accept-encoding"),
+ ("spaced", "Accept-Language , User-Agent"),
+ ] {
+ router.get(RouterPath(path)) { _, _ -> Response in
+ var response = Response(status: .ok)
+
+ response.headers[.vary] = vary
+
+ return response
+ }
+ }
+
+ return Application(router: router)
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift
new file mode 100644
index 0000000..08b392b
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift
@@ -0,0 +1,82 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "Asset protocol",
+ .tags(.`protocol`)
+)
+struct AssetTests {
+
+ // MARK: Properties
+
+ private let image = StubAsset(
+ fileExtensions: [.png],
+ fileName: "icon"
+ )
+ private let shared = StubAsset(
+ fileExtensions: [.css, .js],
+ fileName: "shared"
+ )
+
+ // MARK: Method tests
+
+ @Test
+ func `relative path nests the file inside its extension's sub-directory`() {
+ #expect(shared.relativePath(for: .css) == "css/shared.css")
+ #expect(shared.relativePath(for: .js) == "js/shared.js")
+ }
+
+ @Test
+ func `relative path keeps the file at the root without a sub-directory`() {
+ #expect(image.relativePath(for: .png) == "icon.png")
+ }
+
+ @Test
+ func `url path prefixes the relative path with a slash`() {
+ #expect(shared.urlPath(for: .css) == "/css/shared.css")
+ #expect(image.urlPath(for: .png) == "/icon.png")
+ }
+
+ @Test
+ func `url path appends a version token as a query parameter`() {
+ #expect(shared.urlPath(
+ for: .css,
+ version: "0123456789abcdef"
+ ) == "/css/shared.css?v=0123456789abcdef")
+ }
+
+ @Test(arguments: [nil, ""] as [String?])
+ func `url path without a version`(
+ version: String?
+ ) {
+ #expect(shared.urlPath(
+ for: .css,
+ version: version
+ ) == "/css/shared.css")
+ }
+
+ @Test(arguments: [
+ "",
+ ".",
+ "Resources/Static"
+ ])
+ func `path relative to`(
+ _ basePath: String
+ ) {
+ for fileExtension in shared.fileExtensions {
+ let pathRelativeToBasePath = shared.path(
+ relativeTo: basePath,
+ for: fileExtension
+ )
+ let relativePath = shared.relativePath(for: fileExtension)
+
+ if basePath.isEmpty {
+ #expect(pathRelativeToBasePath == relativePath)
+ } else {
+ #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
+ }
+ }
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
new file mode 100644
index 0000000..e0fc405
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
@@ -0,0 +1,140 @@
+import Elementary
+import Foundation
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "Page protocol",
+ .tags(.`protocol`)
+)
+struct PageTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `assembles the document around the page's parts`() {
+ let html = StubPage().render()
+
+ #expect(html.contains("Stub Page"))
+ #expect(html.contains(#"lang="en""#))
+ #expect(html.contains(#"name="viewport""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains("Stub content"))
+ }
+
+ @Test
+ func `places the metadata between the viewport and the stylesheets`() throws {
+ let html = StubPage().render()
+
+ let viewport = try #require(html.range(of: #"name="viewport""#))
+ let metadata = try #require(html.range(of: #"name="stub""#))
+ let stylesheet = try #require(html.range(of: "/css/stub.css"))
+
+ #expect(viewport.lowerBound < metadata.lowerBound)
+ #expect(metadata.lowerBound < stylesheet.lowerBound)
+ }
+
+ @Test
+ func `renders the scripts after the content`() throws {
+ let html = StubPage().render()
+
+ let content = try #require(html.range(of: "Stub content"))
+ let script = try #require(html.range(of: "/js/stub.js"))
+
+ #expect(content.lowerBound < script.lowerBound)
+ }
+
+ @Test
+ func `omits the summary and canonical tags by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains(#"name="description""#))
+ #expect(!html.contains(#"rel="canonical""#))
+ }
+
+ @Test
+ func `renders the summary and canonical tags when provided`() {
+ let html = StubPage(
+ canonicalURL: "https://stub.example/",
+ summary: "A stub page."
+ ).render()
+
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ }
+
+ @Test
+ func `omits the social card tags by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains(#"property="og:"#))
+ #expect(!html.contains(#"name="twitter:card""#))
+ }
+
+ @Test
+ func `renders the social card tags when provided`() {
+ let html = StubPage(socialCard: .init(
+ title: "Stub Page",
+ summary: "A stub page.",
+ url: "https://stub.example/",
+ image: .init(
+ url: "https://stub.example/img/card.png",
+ width: 2400,
+ height: 1260
+ )
+ )).render()
+
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ }
+
+ @Test
+ func `omits the structured data script by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains("application/ld+json"))
+ }
+
+ @Test
+ func `renders the structured data script when provided`() {
+ let html = StubPage(structuredData: .init(
+ name: "Stub Site",
+ url: "https://stub.example/",
+ logo: "https://stub.example/logo.png",
+ profiles: ["https://social.example/stub"]
+ )).render()
+
+ #expect(html.contains(
+ #""#
+ ))
+ }
+
+ @Test
+ func `appends the version token to the asset URLs`() {
+ let html = StubPage(assetVersion: "0123456789abcdef").render()
+
+ #expect(html.contains("/css/stub.css?v=0123456789abcdef"))
+ #expect(html.contains("/js/stub.js?v=0123456789abcdef"))
+ }
+
+ @Test
+ func `derives the document language from the locale`() {
+ let html = StubPage(locale: .init(identifier: "de-DE")).render()
+
+ #expect(html.contains(#"lang="de""#))
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift
new file mode 100644
index 0000000..6849302
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift
@@ -0,0 +1,91 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "SocialCard type",
+ .tags(.type)
+)
+struct SocialCardTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `derives the full tag list from a complete card`() {
+ let card = SocialCard(
+ title: "A Title",
+ summary: "A summary.",
+ url: "https://site.example/",
+ siteName: "A Site",
+ locale: "en",
+ image: .init(
+ url: "https://site.example/img/card.png",
+ width: 2400,
+ height: 1260,
+ alt: "An image."
+ )
+ )
+
+ #expect(card.tags == [
+ .init("website", name: .type),
+ .init("A Site", name: .siteName),
+ .init("A Title", name: .title),
+ .init("A summary.", name: .description),
+ .init("https://site.example/", name: .url),
+ .init("en", name: .locale),
+ .init("https://site.example/img/card.png", name: .image),
+ .init("2400", name: .imageWidth),
+ .init("1260", name: .imageHeight),
+ .init("An image.", name: .imageAlt),
+ .init("summary_large_image", name: .twitter),
+ ])
+ }
+
+ @Test
+ func `omits the tags of the facts a minimal card does not carry`() {
+ let card = SocialCard(title: "A Title")
+
+ #expect(card.tags == [
+ .init("website", name: .type),
+ .init("A Title", name: .title),
+ .init("summary_large_image", name: .twitter),
+ ])
+ }
+
+ @Test
+ func `omits the image alt tag when the image carries none`() {
+ let card = SocialCard(
+ title: "A Title",
+ image: .init(
+ url: "https://site.example/img/card.png",
+ width: 2400,
+ height: 1260
+ )
+ )
+
+ let names = card.tags.map(\.name)
+
+ #expect(names.contains(.image))
+ #expect(!names.contains(.imageAlt))
+ }
+
+ @Test
+ func `carries the type and style it is given`() {
+ let card = SocialCard(
+ title: "A Title",
+ type: "article",
+ style: .summary
+ )
+
+ #expect(card.tags.contains(.init("article", name: .type)))
+ #expect(card.tags.contains(.init("summary", name: .twitter)))
+ }
+
+ @Test
+ func `keys a tag by the attribute its name dictates`() {
+ #expect(SocialCard.Tag.Name.twitter.attribute == .name)
+ #expect(SocialCard.Tag.Name.title.attribute == .property)
+ #expect(SocialCard.Tag("website", name: .type).attribute == .property)
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift
new file mode 100644
index 0000000..f0bfcb3
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift
@@ -0,0 +1,159 @@
+import Foundation
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "StructuredData type",
+ .tags(.type)
+)
+struct StructuredDataTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `derives the full payload from complete data`() {
+ let data = StructuredData(
+ name: "A Site",
+ url: "https://site.example/",
+ logo: "https://site.example/logo.png",
+ profiles: [
+ "https://social.example/a-site",
+ "https://videos.example/a-site",
+ ]
+ )
+
+ #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
+ #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
+ #""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
+ #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
+ )
+ }
+
+ @Test
+ func `omits the properties of the facts minimal data does not carry`() {
+ let data = StructuredData(
+ name: "A Site",
+ url: "https://site.example/"
+ )
+
+ #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
+ #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
+ #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
+ )
+ }
+
+ @Test
+ func `renders a composed node graph`() {
+ let data = StructuredData(nodes: [
+ .init(
+ type: "MusicEvent",
+ properties: [
+ .init(.name, value: .string("A Gig")),
+ .init("location", value: .node(.init(
+ type: "Place",
+ properties: [
+ .init(.name, value: .string("A Venue")),
+ ]
+ ))),
+ .init("organizer", value: .reference("https://site.example/#organization")),
+ ]
+ ),
+ ])
+
+ #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
+ #"{"@type":"MusicEvent","name":"A Gig","location":{"@type":"Place","name":"A Venue"},"# +
+ #""organizer":{"@id":"https://site.example/#organization"}}]}"#
+ )
+ }
+
+ @Test
+ func `escapes the values it embeds in the payload`() {
+ let data = StructuredData(
+ name: #"A "Quoted" \ Site"#,
+ url: "https://site.example/"
+ )
+
+ #expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
+ // The `<` is escaped so a value can never close the script tag embedding the payload.
+ #expect(!data.payload.contains(""))
+ #expect(data.payload.contains("\\" + "u003c/script>"))
+ }
+
+ @Test
+ func `renders a node fragment with its identifier`() {
+ let node = StructuredData.Node(
+ type: "Organization",
+ id: "https://site.example/#organization",
+ properties: [
+ .init(.name, value: .string("A Site")),
+ ]
+ )
+
+ #expect(node.fragment == #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site"}"#)
+ }
+
+ @Test
+ func `renders a node fragment without an identifier or properties`() {
+ let node = StructuredData.Node(
+ type: "Organization",
+ properties: []
+ )
+
+ #expect(node.fragment == #"{"@type":"Organization"}"#)
+ }
+
+ @Test
+ func `renders the common names and kinds by their schema.org spelling`() {
+ #expect(StructuredData.Property.Name.logo.rawValue == "logo")
+ #expect(StructuredData.Property.Name.name.rawValue == "name")
+ #expect(StructuredData.Property.Name.publisher.rawValue == "publisher")
+ #expect(StructuredData.Property.Name.sameAs.rawValue == "sameAs")
+ #expect(StructuredData.Property.Name.url.rawValue == "url")
+ #expect(StructuredData.Node.Kind.organization.rawValue == "Organization")
+ #expect(StructuredData.Node.Kind.website.rawValue == "WebSite")
+ }
+
+ @Test
+ func `renders the fragment of every value case`() {
+ #expect(StructuredData.Value.string("A Value").fragment == #""A Value""#)
+ #expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#)
+ #expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#)
+ #expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#)
+ }
+
+ @Test
+ func `renders a string as a quoted literal`() {
+ #expect(StructuredData.Value.literal("A Value") == #""A Value""#)
+ #expect(StructuredData.Value.literal("") == "\"\"")
+ }
+
+ @Test
+ func `pads the escape of a control character to four digits`() {
+ #expect(StructuredData.Value.literal("\u{0}") == "\"" + "\\" + "u0000" + "\"")
+ #expect(StructuredData.Value.literal("\u{1f}") == "\"" + "\\" + "u001f" + "\"")
+ #expect(StructuredData.Value.literal("\u{a}") == "\"" + "\\" + "u000a" + "\"")
+ // The first scalar past the control range passes through untouched.
+ #expect(StructuredData.Value.literal(" ") == #"" ""#)
+ }
+
+ @Test
+ func `derives a payload that parses back to the facts it carries`() throws {
+ let name = "A \"Site\"\nwith \\ every "
+ let data = StructuredData(
+ name: name,
+ url: "https://site.example/",
+ logo: "https://site.example/logo.png",
+ profiles: ["https://social.example/a-site"]
+ )
+
+ let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
+ let graph = try #require((object as? [String: Any])?["@graph"] as? [[String: Any]])
+
+ #expect(graph.count == 2)
+ #expect(graph[0]["name"] as? String == name)
+ #expect(graph[0]["sameAs"] as? [String] == ["https://social.example/a-site"])
+ #expect(graph[1]["url"] as? String == "https://site.example/")
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings
new file mode 100644
index 0000000..897a3e0
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings
@@ -0,0 +1,23 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "test.greeting" : {
+ "comment" : "Fixture string used by the Infrastructure test suite.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hallo"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hello"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift
new file mode 100644
index 0000000..c508008
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift
@@ -0,0 +1,11 @@
+import Infrastructure
+
+/// An ``Asset`` with a fixed file name and set of extensions.
+struct StubAsset: Asset {
+
+ // MARK: Properties
+
+ let fileExtensions: [AssetExtension]
+ let fileName: String
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift
new file mode 100644
index 0000000..aa28122
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift
@@ -0,0 +1,26 @@
+import Hummingbird
+import Infrastructure
+
+/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only.
+struct StubRequestContext: LocalizedRequestContext {
+
+ // MARK: Properties
+
+ /// The core request context storage Hummingbird requires.
+ var coreContext: CoreRequestContextStorage
+
+ /// The language identifier negotiated for the request.
+ var language: String
+
+ // MARK: Initializers
+
+ /// Creates a request context for the given source.
+ /// - Parameter source: the source the context is initialized from.
+ init(
+ source: Source
+ ) {
+ self.coreContext = .init(source: source)
+ self.language = ""
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift
new file mode 100644
index 0000000..48e2e24
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift
@@ -0,0 +1,30 @@
+import Hummingbird
+import Infrastructure
+
+/// A controller serving its path back as plain text, used to observe route registration.
+struct StubController {
+
+ // MARK: Properties
+
+ /// The path the controller serves, also returned as the response body.
+ let path: String
+
+}
+
+// MARK: - RouterController
+
+extension StubController: RouterController {
+
+ // MARK: Properties
+
+ var routes: RouteCollection {
+ let routes = RouteCollection(context: BasicRequestContext.self)
+
+ routes.get(.init(path)) { _, _ in
+ self.path
+ }
+
+ return routes
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift
new file mode 100644
index 0000000..1117cd8
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift
@@ -0,0 +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 a protocol scaffolding of the Infrastructure package.
+ @Tag static var `protocol`: Tag
+ /// Tests exercising a type of the Infrastructure package.
+ @Tag static var type: Tag
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
new file mode 100644
index 0000000..ffc755e
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
@@ -0,0 +1,94 @@
+import Elementary
+import Foundation
+import Infrastructure
+
+/// A ``Page`` with fixed content, metadata, and stub assets.
+struct StubPage: Page {
+
+ // MARK: Properties
+
+ /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
+ let assetVersion: String?
+
+ /// The canonical URL rendered in the document head, or `nil` to omit it.
+ let canonicalURL: String?
+
+ /// The locale the page content is localized to.
+ let locale: Locale
+
+ /// The card rendered as link-preview tags in the document head, or `nil` to omit them.
+ let socialCard: SocialCard?
+
+ /// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
+ let structuredData: StructuredData?
+
+ /// The summary rendered in the document head, or `nil` to omit it.
+ let summary: String?
+
+ // MARK: Initializers
+
+ /// Creates a stub page.
+ /// - Parameters:
+ /// - locale: the locale the page content is localized to. Defaults to `en`.
+ /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
+ /// default) to leave them unversioned.
+ /// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
+ /// to omit it.
+ /// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
+ /// (the default) to omit them.
+ /// - structuredData: the structured data rendered as a JSON-LD script in the document
+ /// head, or `nil` (the default) to omit it.
+ /// - summary: the summary rendered in the document head, or `nil` (the default)
+ /// to omit it.
+ init(
+ locale: Locale = .init(identifier: "en"),
+ assetVersion: String? = nil,
+ canonicalURL: String? = nil,
+ socialCard: SocialCard? = nil,
+ structuredData: StructuredData? = nil,
+ summary: String? = nil
+ ) {
+ self.assetVersion = assetVersion
+ self.canonicalURL = canonicalURL
+ self.locale = locale
+ self.socialCard = socialCard
+ self.structuredData = structuredData
+ self.summary = summary
+ }
+
+ // MARK: Computed
+
+ var content: some HTML {
+ p { "Stub content" }
+ }
+
+ var lang: String {
+ locale.language.languageCode?.identifier ?? "en"
+ }
+
+ var metadata: some HTML {
+ meta(
+ .name("stub"),
+ .content("marker")
+ )
+ }
+
+ var scripts: [any Asset] {
+ [StubAsset(
+ fileExtensions: [.css, .js],
+ fileName: "stub"
+ )]
+ }
+
+ var stylesheets: [any Asset] {
+ [StubAsset(
+ fileExtensions: [.css, .js],
+ fileName: "stub"
+ )]
+ }
+
+ var title: String {
+ "Stub Page"
+ }
+
+}
diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md
new file mode 100644
index 0000000..e0a1c40
--- /dev/null
+++ b/Packages/Localization/README.md
@@ -0,0 +1,44 @@
+# Localization
+The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
+
+## Overview
+The package provides, grouped by role:
+| Role | Types |
+| --- | --- |
+| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
+| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 |
+| Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines |
+| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
+
+## Design rules
+- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`. Adding a language is a translation-only change — once a locale exists in the catalog, `LanguageList` and `Negotiate` pick it up with no code change.
+- **The locale is always explicit.** A server has no single "current" locale, so every lookup names the locale to resolve in; nothing reads process-wide locale state.
+- **Raw `.xcstrings` parsing, for Linux parity.** The catalog is decoded from its JSON rather than through Foundation's compiled-catalog APIs, which are unavailable or non-functional on Linux. Consumers must `.copy` the catalog resource verbatim (not `.process` it) so it ships as raw JSON on every platform. Only simple `stringUnit` values are decoded; plural and device variations are not represented.
+- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` once at startup and warn when it is not `.loaded`, before visitors ever see raw keys.
+- **Method structs.** `Localize` and `Negotiate` hold their lifetime-fixed configuration (the bundle) in `init` and take only per-call inputs in `callAsFunction`.
+- **One decoded catalog per bundle.** Catalogs are immutable at runtime, so every `Localize`, `Negotiate`, and `LanguageList` bound to the same bundle and table shares one cached `StringCatalog`.
+
+## Layout
+Sources are split by visibility, then by kind, one type per file:
+```
+Sources/
+├── Public/
+│ ├── Enumerations/ CatalogState
+│ ├── Methods/ Localize, Negotiate
+│ └── Types/ LanguageList
+└── Internal/
+ ├── Protocols/ CatalogResolving, the seam between the public API and the catalog backend
+ └── Types/ StringCatalog (the cached .xcstrings decoder), LanguageRange
+Tests/
+├── Cases/ the test suites, mirroring the Sources/ layout
+├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux
+└── Utils/ the StubCatalog resolver and the suite Tag constants
+```
+
+## Testing
+Every suite carries a tag naming the kind of API it exercises — `.method` 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`).
+- macOS 15, matching the sibling `Infrastructure` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- No package dependencies — Foundation and Synchronization only.
diff --git a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift
index bb003fd..9a30292 100644
--- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift
+++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift
@@ -2,23 +2,24 @@ import Foundation
/// A backend that resolves localized strings for an explicit locale and reports the languages it serves.
///
-/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored
-/// and resolved. The shipping implementation, ``StringCatalog``, reads a raw `.xcstrings` catalog so it
-/// behaves identically on Darwin and Linux. A future backend — for example one built on
-/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform
-/// without changing any caller.
+/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored and resolved. The shipping implementation,
+/// ``StringCatalog``, reads a raw `.xcstrings` catalog so it behaves identically on Darwin and Linux. A future backend — for example one built on
+/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform without changing any caller.
///
-/// Resolution never fails: an implementation returns the key itself when it has no localization for it,
-/// mirroring Foundation's `String(localized:)`. This is the contract both a dictionary lookup and the
-/// native API can honour, since the native API cannot distinguish a missing key from a translation that
+/// Resolution never fails: an implementation returns the key itself when it has no localization for it, mirroring Foundation's `String(localized:)`.
+/// This is the contract both a dictionary lookup and the native API can honour, since the native API cannot distinguish a missing key from a translation that
/// happens to equal the key.
protocol CatalogResolving: Sendable {
// MARK: Properties
- /// The source (development) language, used as the final fallback when a locale has no localization.
+ /// The source (development) language, used as the final fallback when a locale has no localization and as the default language a ``LanguageList``
+ /// serves.
var sourceLanguage: String { get }
+ /// The outcome of reading the backing catalog, for consumers to surface at startup.
+ var state: CatalogState { get }
+
/// Every language the backend can resolve strings for, including the source language.
var languages: Set { get }
diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift
new file mode 100644
index 0000000..b4c203d
--- /dev/null
+++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift
@@ -0,0 +1,16 @@
+/// A single language range parsed from an `Accept-Language` header entry.
+///
+/// A range pairs the language tag a client asked for with the `q` weight expressing how much the client prefers it, as RFC 9110 defines them.
+/// ``Negotiate`` parses each comma-separated header entry into one of these, then orders the ranges by descending weight so the most preferred tag
+/// is matched first.
+struct LanguageRange {
+
+ // MARK: Properties
+
+ /// The language tag the client asked for, such as `de` or `de-AT`, or the `*` wildcard.
+ let tag: String
+
+ /// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when an entry names no weight).
+ let quality: Double
+
+}
diff --git a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift
index 477c33c..de68dbc 100644
--- a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift
+++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift
@@ -1,4 +1,5 @@
import Foundation
+import Synchronization
/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources.
///
@@ -18,6 +19,9 @@ struct StringCatalog: Sendable {
/// The resolved entries, keyed by catalog key then by language code.
let entries: [String: [String: String]]
+ /// The outcome of reading the catalog from its bundle.
+ let state: CatalogState
+
// MARK: Computed
/// Every language the catalog provides a localization for, including the source language.
@@ -46,8 +50,9 @@ struct StringCatalog: Sendable {
forResource: table,
withExtension: .Extension.stringCatalog
) else {
- self.sourceLanguage = "en"
+ self.sourceLanguage = .Default.sourceLanguage
self.entries = [:]
+ self.state = .missing
return
}
@@ -65,9 +70,59 @@ struct StringCatalog: Sendable {
(entry.localizations ?? [:])
.compactMapValues { $0.stringUnit?.value }
}
+ self.state = .loaded
} catch {
- self.sourceLanguage = "en"
+ self.sourceLanguage = .Default.sourceLanguage
self.entries = [:]
+ self.state = .undecodable
+ }
+ }
+
+}
+
+// MARK: - Cache
+
+extension StringCatalog {
+
+ /// A cache key identifying a catalog by its bundle location and table name.
+ private struct Key: Hashable, Sendable {
+ let bundle: URL
+ let table: String
+ }
+
+ /// The decoded catalogs, keyed by bundle and table.
+ private static let cache = Mutex<[Key: StringCatalog]>([:])
+
+ /// Returns the catalog for the given bundle and table, decoding it on first access.
+ ///
+ /// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` bound to the same bundle shares one
+ /// decoded catalog instead of re-reading its JSON.
+ /// - Parameters:
+ /// - bundle: the bundle whose resources contain the String Catalog.
+ /// - table: the name of the String Catalog resource, without the `.xcstrings` extension.
+ /// - Returns: the decoded catalog, from the cache when it has been read before.
+ static func cached(
+ bundle: Bundle,
+ table: String = "Localizable"
+ ) -> StringCatalog {
+ let key = Key(
+ bundle: bundle.bundleURL,
+ table: table
+ )
+
+ return cache.withLock { cache in
+ if let catalog = cache[key] {
+ return catalog
+ }
+
+ let catalog = StringCatalog(
+ bundle: bundle,
+ table: table
+ )
+
+ cache[key] = catalog
+
+ return catalog
}
}
@@ -77,6 +132,8 @@ struct StringCatalog: Sendable {
extension StringCatalog: CatalogResolving {
+ /// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its primary language code (`pt`), then the source
+ /// language, then the key itself. Tags are matched case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for.
func string(
for key: String,
in locale: Locale
@@ -85,15 +142,46 @@ extension StringCatalog: CatalogResolving {
return key
}
- let language = locale
- .language
- .languageCode?
- .identifier
- ?? sourceLanguage
+ for tag in locale.catalogTags {
+ if let match = byLanguage.first(
+ where: { $0.key.lowercased() == tag }
+ ) {
+ return match.value
+ }
+ }
- return byLanguage[language]
- ?? byLanguage[sourceLanguage]
- ?? key
+ return byLanguage[sourceLanguage] ?? key
+ }
+
+}
+
+// MARK: - Locale+Extensions
+
+private extension Locale {
+
+ /// The language tags to resolve a catalog entry against, most specific first.
+ ///
+ /// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes `pt-br`), followed by its primary language code when
+ /// the two differ.
+ var catalogTags: [String] {
+ var tags: [String] = []
+ let identifier = identifier
+ .replacingOccurrences(
+ of: String.Separator.underscore,
+ with: String.Separator.dash
+ )
+ .lowercased()
+
+ if !identifier.isEmpty {
+ tags.append(identifier)
+ }
+
+ if let code = language.languageCode?.identifier.lowercased(),
+ code != identifier {
+ tags.append(code)
+ }
+
+ return tags
}
}
@@ -127,7 +215,18 @@ private extension StringCatalog {
// MARK: - String+Constants
private extension String {
+ /// The source language assumed when a catalog is missing or cannot be decoded, matching the
+ /// package's default localization.
+ enum Default {
+ static let sourceLanguage = "en"
+ }
+
enum Extension {
static let stringCatalog = "xcstrings"
}
+
+ enum Separator {
+ static let dash = "-"
+ static let underscore = "_"
+ }
}
diff --git a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift
new file mode 100644
index 0000000..56d3ceb
--- /dev/null
+++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift
@@ -0,0 +1,12 @@
+/// The outcome of reading a String Catalog from its bundle.
+///
+/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the language list falls back to the default language — so
+/// nothing fails at the call site. This state is the signal a server checks once at startup to warn before visitors ever see raw localization keys.
+public enum CatalogState: Equatable, Sendable {
+ /// The catalog was found and decoded; its entries are resolvable.
+ case loaded
+ /// No catalog resource exists in the bundle; every lookup returns its key.
+ case missing
+ /// The catalog resource exists but is not valid `.xcstrings` JSON; every lookup returns its key.
+ case undecodable
+}
diff --git a/Packages/Localization/Sources/Public/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift
index e2c18a6..b45e264 100644
--- a/Packages/Localization/Sources/Public/Methods/Localize.swift
+++ b/Packages/Localization/Sources/Public/Methods/Localize.swift
@@ -2,9 +2,8 @@ import Foundation
/// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale.
///
-/// A server has no single "current" locale, so each lookup must name the locale to use. An instance
-/// is bound to the bundle whose catalog holds the strings, then invoked like a function to resolve a
-/// key in a chosen locale.
+/// A server has no single "current" locale, so each lookup must name the locale to use. An instance is bound to the bundle whose catalog holds the strings,
+/// then invoked like a function to resolve a key in a chosen locale.
public struct Localize: Sendable {
// MARK: Properties
@@ -12,6 +11,15 @@ public struct Localize: Sendable {
/// The backend that resolves keys against the catalog.
private let resolver: any CatalogResolving
+ // MARK: Computed
+
+ /// The outcome of reading the bundle's String Catalog.
+ ///
+ /// Resolution degrades to returning raw keys rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
+ public var catalogState: CatalogState {
+ resolver.state
+ }
+
// MARK: Initializers
/// Creates a localizer backed by the String Catalog in the given bundle.
@@ -22,7 +30,7 @@ public struct Localize: Sendable {
bundle: Bundle,
table: String = "Localizable"
) {
- self.init(resolver: StringCatalog(
+ self.init(resolver: StringCatalog.cached(
bundle: bundle,
table: table
))
@@ -46,8 +54,8 @@ public struct Localize: Sendable {
/// - Parameters:
/// - key: the String Catalog key to look up.
/// - locale: the locale to resolve the key in.
- /// - Returns: the localized string for the locale, the source-language string when the locale has no
- /// entry, or the key itself when the catalog has no entry for it.
+ /// - Returns: the localized string for the locale, the source-language string when the locale has no entry, or the key itself when the catalog has no
+ /// entry for it.
public func callAsFunction(
_ key: String,
locale: Locale
diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift
index 878b649..ee41bed 100644
--- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift
+++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift
@@ -2,9 +2,9 @@ import Foundation
/// Negotiates the best supported language for a request from its `Accept-Language` header.
///
-/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a
-/// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a
-/// supported language identifier, falling back to the default language.
+/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a function — through
+/// ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a supported language identifier, honouring the header's `q` weights as
+/// RFC 9110 prescribes and falling back to the default language.
public struct Negotiate: Sendable {
// MARK: Properties
@@ -27,10 +27,10 @@ public struct Negotiate: Sendable {
/// Picks the best supported language for the given `Accept-Language` header value.
///
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
- /// The header is split into its language tags (dropping any `q` weights), then each tag is matched
- /// against the supported languages in order of preference — first by an exact match, then by its
- /// primary language subtag, so `de-AT` resolves to a supported `de`. When the header is absent or
- /// matches nothing, the default language is returned.
+ /// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight
+ /// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the
+ /// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the
+ /// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned.
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
/// - Returns: the identifier of the supported language to serve.
public func callAsFunction(
@@ -40,16 +40,20 @@ public struct Negotiate: Sendable {
return list.default
}
- let tags = tags(from: language)
+ let ranges = ranges(from: language)
- guard !tags.isEmpty else {
+ guard !ranges.isEmpty else {
return list.default
}
let supported = list.all
- for tag in tags {
- if let match = match(tag: tag, in: supported) {
+ for range in ranges {
+ if range.tag == .wildcard {
+ return list.default
+ }
+
+ if let match = match(range.tag, in: supported) {
return match
}
}
@@ -65,39 +69,79 @@ private extension Negotiate {
// MARK: Methods
- /// Extracts the ordered language tags from an `Accept-Language` header value.
+ /// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference.
///
- /// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and
- /// blank entries are removed. The original order is kept, which mirrors descending preference
- /// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly.
+ /// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by descending weight, entries weighted 0 are dropped
+ /// as "not acceptable", and equally weighted entries keep the header order.
/// - Parameter acceptLanguage: the raw `Accept-Language` header value.
- /// - Returns: the ordered, weight-stripped language tags.
- func tags(
+ /// - Returns: the language ranges, most preferred first.
+ func ranges(
from acceptLanguage: String
- ) -> [String] {
+ ) -> [LanguageRange] {
acceptLanguage
.split(separator: .Separator.comma)
- .map { entry in
- entry
- .split(separator: .Separator.semicolon)
- .first
- .map(String.init)?
- .trimmingCharacters(in: .whitespaces)
- ?? .empty
+ .compactMap(range(from:))
+ .filter { $0.quality > 0 }
+ .enumerated()
+ .sorted { lhs, rhs in
+ lhs.element.quality == rhs.element.quality
+ ? lhs.offset < rhs.offset
+ : lhs.element.quality > rhs.element.quality
}
- .filter { !$0.isEmpty }
+ .map(\.element)
+ }
+
+ /// Parses a single `Accept-Language` header entry into its ``LanguageRange``.
+ ///
+ /// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight.
+ /// A missing or malformed weight counts as 1, the highest preference, matching a tag sent without one.
+ /// - Parameter entry: a single comma-separated header entry.
+ /// - Returns: the entry's language range, or `nil` when it has no language tag.
+ func range(
+ from entry: Substring
+ ) -> LanguageRange? {
+ let parts = entry.split(separator: .Separator.semicolon)
+ let tag = parts.first
+ .map(String.init)?
+ .trimmingCharacters(in: .whitespaces)
+ ?? .empty
+
+ guard !tag.isEmpty else {
+ return nil
+ }
+
+ let quality = parts
+ .dropFirst()
+ .compactMap { parameter -> Double? in
+ let parameter = parameter
+ .trimmingCharacters(in: .whitespaces)
+ .lowercased()
+
+ guard parameter.hasPrefix(.Prefix.quality) else {
+ return nil
+ }
+
+ return Double(parameter.dropFirst(String.Prefix.quality.count))
+ }
+ .first
+ ?? 1
+
+ return .init(
+ tag: tag,
+ quality: quality
+ )
}
/// Finds the supported language that best matches a single `Accept-Language` tag.
///
- /// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the
- /// supported languages' primary subtags, so a regional tag such as `de-AT` resolves to `de`.
+ /// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the supported languages' primary subtags, so a
+ /// regional tag such as `de-AT` resolves to `de`.
/// - Parameters:
/// - tag: a single language tag from the header.
/// - supported: the supported language identifiers.
/// - Returns: the matching supported language, or `nil` when the tag matches none.
func match(
- tag: String,
+ _ tag: String,
in supported: [String]
) -> String? {
let tag = tag.lowercased()
@@ -117,10 +161,32 @@ private extension Negotiate {
}
+// MARK: - Character+Extensions
+
+private extension Character {
+
+ // MARK: Constants
+
+ enum Separator {
+ static let comma: Character = ","
+ static let dash: Character = "-"
+ static let semicolon: Character = ";"
+ }
+}
+
+
// MARK: - String+Extensions
private extension String {
+
+ // MARK: Constants
+
static let empty: String = ""
+ static let wildcard: String = "*"
+
+ enum Prefix {
+ static let quality = "q="
+ }
/// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`).
var primarySubtag: String {
@@ -130,13 +196,3 @@ private extension String {
?? self
}
}
-
-// MARK: - Constants
-
-private extension Character {
- enum Separator {
- static let comma: Character = ","
- static let dash: Character = "-"
- static let semicolon: Character = ";"
- }
-}
diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift
index eff831a..d3b666c 100644
--- a/Packages/Localization/Sources/Public/Types/LanguageList.swift
+++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift
@@ -8,46 +8,45 @@ public struct LanguageList: Sendable {
// MARK: Properties
- /// The language served when none of the supported languages match a request.
- ///
- /// Should match the catalog bundle's development localization.
- public let `default`: String
-
/// The backend that reports the available languages.
private let resolver: any CatalogResolving
// MARK: Initializers
/// Creates a language list backed by the given bundle.
- /// - Parameters:
- /// - bundle: the bundle whose String Catalog defines the available languages.
- /// - default: the language served when none of the supported languages match. Defaults to `"en"`.
+ /// - Parameter bundle: the bundle whose String Catalog defines the available languages.
public init(
- bundle: Bundle,
- `default`: String = "en"
+ bundle: Bundle
) {
- self.init(
- resolver: StringCatalog(bundle: bundle),
- default: `default`
- )
+ self.init(resolver: StringCatalog.cached(bundle: bundle))
}
/// Creates a language list backed by the given resolver.
///
/// The seam for tests and alternative backends; the public API derives languages from a bundled catalog.
- /// - Parameters:
- /// - resolver: the backend that reports the available languages.
- /// - default: the language served when none of the supported languages match.
+ /// - Parameter resolver: the backend that reports the available languages.
init(
- resolver: any CatalogResolving,
- `default`: String = "en"
+ resolver: any CatalogResolving
) {
self.resolver = resolver
- self.`default` = `default`
}
// MARK: Computed
+ /// The outcome of reading the bundle's String Catalog.
+ ///
+ /// The list degrades to the default language rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
+ public var catalogState: CatalogState {
+ resolver.state
+ }
+
+ /// The language served when none of the supported languages match a request.
+ ///
+ /// Always the catalog's source (development) language, so the default cannot drift from the bundle the list is bound to.
+ public var `default`: String {
+ resolver.sourceLanguage
+ }
+
/// Every language the bundle's String Catalog provides a localization for.
///
/// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language.
diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift
index 502d226..3ef1c67 100644
--- a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift
+++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift
@@ -3,7 +3,10 @@ import Testing
@testable import Localization
-@Suite("Localize method")
+@Suite(
+ "Localize method",
+ .tags(.method)
+)
struct LocalizeTests {
// MARK: Constants
@@ -32,6 +35,36 @@ struct LocalizeTests {
#expect(text == "Hallo")
}
+ @Test
+ func `resolves a key in a regional locale`() {
+ let text = localize(
+ "test.greeting",
+ locale: Locale(identifier: "pt-BR"),
+ )
+
+ #expect(text == "Olá")
+ }
+
+ @Test
+ func `falls back to the primary language for an unmatched regional locale`() {
+ let text = localize(
+ "test.greeting",
+ locale: Locale(identifier: "de-AT"),
+ )
+
+ #expect(text == "Hallo")
+ }
+
+ @Test
+ func `falls back to the source language for an unsupported locale`() {
+ let text = localize(
+ "test.greeting",
+ locale: Locale(identifier: "fr"),
+ )
+
+ #expect(text == "Hello")
+ }
+
@Test
func `falls back to the key for an unknown entry`() {
let text = localize(
@@ -42,4 +75,60 @@ struct LocalizeTests {
#expect(text == "unknown.key")
}
+ @Test
+ func `falls back to the key for a missing catalog`() {
+ let localize = Localize(bundle: .main)
+
+ let text = localize(
+ "test.greeting",
+ locale: Locale(identifier: "en")
+ )
+
+ #expect(text == "test.greeting")
+ }
+
+ // MARK: Properties tests
+
+ @Test
+ func `reports a loaded catalog`() {
+ #expect(localize.catalogState == .loaded)
+ }
+
+ @Test
+ func `reports a missing catalog`() {
+ let localize = Localize(bundle: .main)
+
+ #expect(localize.catalogState == .missing)
+ }
+
+ // A malformed catalog cannot ship as a test resource — Xcode generates string symbols for every
+ // bundled `.xcstrings` and fails the build on invalid JSON — so one is staged in a temporary
+ // directory bundle instead.
+ @Test
+ func `reports an undecodable catalog`() throws {
+ let directory = URL(fileURLWithPath: NSTemporaryDirectory())
+ .appendingPathComponent(
+ "UndecodableCatalog-\(UUID().uuidString)",
+ isDirectory: true
+ )
+
+ try FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+
+ defer {
+ try? FileManager.default.removeItem(at: directory)
+ }
+
+ try Data("not a catalog".utf8).write(
+ to: directory.appendingPathComponent("Localizable.xcstrings")
+ )
+
+ let bundle = try #require(Bundle(url: directory))
+ let localize = Localize(bundle: bundle)
+
+ #expect(localize.catalogState == .undecodable)
+ }
+
}
diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift
index 4ab3160..f7cb0bc 100644
--- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift
+++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift
@@ -3,7 +3,10 @@ import Testing
@testable import Localization
-@Suite("Negotiate method")
+@Suite(
+ "Negotiate method",
+ .tags(.method)
+)
struct NegotiateTests {
// MARK: Constants
@@ -26,6 +29,20 @@ struct NegotiateTests {
#expect(language == "de")
}
+ @Test
+ func `matches a regional catalog language exactly`() {
+ let language = negotiate(acceptLanguage: "pt-BR")
+
+ #expect(language == "pt-BR")
+ }
+
+ @Test
+ func `matches a regional catalog language by its primary subtag`() {
+ let language = negotiate(acceptLanguage: "pt")
+
+ #expect(language == "pt-BR")
+ }
+
@Test
func `respects the header order of preference`() {
let language = negotiate(acceptLanguage: "de, en")
@@ -34,12 +51,47 @@ struct NegotiateTests {
}
@Test
- func `strips quality weights from the language tags`() {
- let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9")
+ func `orders the language tags by descending quality weight`() {
+ let language = negotiate(acceptLanguage: "en;q=0.5, de;q=0.9")
#expect(language == "de")
}
+ @Test
+ func `treats a tag without a weight as the highest preference`() {
+ let language = negotiate(acceptLanguage: "en;q=0.9, de")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `treats a tag with a malformed weight as the highest preference`() {
+ let language = negotiate(acceptLanguage: "en;q=0.9, de;q=broken")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `drops language tags weighted as not acceptable`() {
+ let language = negotiate(acceptLanguage: "de;q=0, en;q=0.8")
+
+ #expect(language == "en")
+ }
+
+ @Test
+ func `falls back to the default when every tag is weighted as not acceptable`() {
+ let language = negotiate(acceptLanguage: "de;q=0, fr;q=0")
+
+ #expect(language == "en")
+ }
+
+ @Test
+ func `serves the default language for a wildcard`() {
+ let language = negotiate(acceptLanguage: "fr;q=0.9, *;q=0.5")
+
+ #expect(language == "en")
+ }
+
@Test
func `trims whitespace around the language tags`() {
let language = negotiate(acceptLanguage: " de , en ")
diff --git a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift
index 4e144e2..0c3829b 100644
--- a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift
+++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift
@@ -3,7 +3,10 @@ import Testing
@testable import Localization
-@Suite("LanguageList type")
+@Suite(
+ "LanguageList type",
+ .tags(.type)
+)
struct LanguageListTests {
// MARK: Properties tests
@@ -20,16 +23,39 @@ struct LanguageListTests {
}
@Test
- func `uses the provided default language`() {
+ func `follows the catalog's source language`() {
let list = LanguageList(
- bundle: .module,
- default: "de"
+ resolver: StubCatalog(
+ sourceLanguage: "de",
+ languages: ["de", "en"]
+ )
)
#expect(list.default == "de")
}
}
+ @Suite("catalogState")
+ struct CatalogState {
+ @Test
+ func `reports a loaded catalog`() {
+ let list = LanguageList(
+ bundle: .module
+ )
+
+ #expect(list.catalogState == .loaded)
+ }
+
+ @Test
+ func `reports a missing catalog`() {
+ let list = LanguageList(
+ bundle: .main
+ )
+
+ #expect(list.catalogState == .missing)
+ }
+ }
+
@Suite("all")
struct All {
@Test
@@ -45,20 +71,34 @@ struct LanguageListTests {
@Test
func `excludes the base localization`() {
let list = LanguageList(
- bundle: .module
+ resolver: StubCatalog(
+ sourceLanguage: "en",
+ languages: ["Base", "de", "en"]
+ )
)
- #expect(!list.all.contains("Base"))
+ #expect(list.all == ["de", "en"])
}
@Test
- func `lists each language only once`() {
+ func `sorts the languages`() {
let list = LanguageList(
- bundle: .module
+ resolver: StubCatalog(
+ sourceLanguage: "en",
+ languages: ["fr", "en", "de"]
+ )
)
- let all = list.all
- #expect(all.count == Set(all).count)
+ #expect(list.all == ["de", "en", "fr"])
+ }
+
+ @Test
+ func `degrades to the default language for a missing catalog`() {
+ let list = LanguageList(
+ bundle: .main
+ )
+
+ #expect(list.all == ["en"])
}
}
diff --git a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings
index 509d868..7135307 100644
--- a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings
+++ b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings
@@ -15,6 +15,12 @@
"state" : "translated",
"value" : "Hello"
}
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Olá"
+ }
}
}
}
diff --git a/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift
new file mode 100644
index 0000000..1c1e8cf
--- /dev/null
+++ b/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift
@@ -0,0 +1,8 @@
+import Testing
+
+extension Tag {
+ /// Tests exercising a method of the Localization package.
+ @Tag static var method: Tag
+ /// Tests exercising a type of the Localization package.
+ @Tag static var type: Tag
+}
diff --git a/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift
new file mode 100644
index 0000000..1bd3ead
--- /dev/null
+++ b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+@testable import Localization
+
+/// A ``CatalogResolving`` backend with fixed languages, for exercising types apart from a bundled catalog.
+struct StubCatalog: CatalogResolving {
+
+ // MARK: Properties
+
+ let sourceLanguage: String
+ let languages: Set
+ var state: CatalogState = .loaded
+
+ // MARK: Methods
+
+ func string(
+ for key: String,
+ in locale: Locale
+ ) -> String {
+ key
+ }
+
+}
diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift
index ca63107..5d83c1f 100644
--- a/Packages/Persistence/Package.swift
+++ b/Packages/Persistence/Package.swift
@@ -32,6 +32,14 @@ let package = Package(
url: "https://github.com/vapor/sql-kit.git",
from: "3.36.0"
),
+ .package(
+ url: "https://github.com/vapor/mysql-nio.git",
+ from: "1.7.0"
+ ),
+ .package(
+ url: "https://github.com/apple/swift-nio.git",
+ from: "2.65.0"
+ ),
],
targets: [
.target(
@@ -59,7 +67,19 @@ let package = Package(
.testTarget(
name: "PersistenceTests",
dependencies: [
- .byName(name: "Persistence")
+ .byName(name: "Persistence"),
+ .product(
+ name: "MySQLNIO",
+ package: "mysql-nio"
+ ),
+ .product(
+ name: "NIOCore",
+ package: "swift-nio"
+ ),
+ .product(
+ name: "NIOPosix",
+ package: "swift-nio"
+ ),
],
path: "Tests"
),
diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md
new file mode 100644
index 0000000..571fdac
--- /dev/null
+++ b/Packages/Persistence/README.md
@@ -0,0 +1,60 @@
+# Persistence
+The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a MySQL/MariaDB backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
+
+## Overview
+The package provides, grouped by role:
+| Role | Types |
+| --- | --- |
+| Backend selection | `Driver` (`mysql` or `inMemory`), `Configuration` (the MySQL/MariaDB connection parameters), `TLS` (the connection's TLS posture) |
+| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
+| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
+| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` |
+| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
+
+## Design rules
+- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. See the Website service's `ConfigReader+Properties` for the mapping.
+- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
+- **Migrations are declared once, and append-only.** `PrepareDB` is the single place migrations are registered, in the order they must run; alter the schema by adding a new migration, never by editing one that has already run. Registering does not apply them — the in-memory backend is migrated on startup, while a shared MySQL/MariaDB database is migrated out of band (the executable's migrate-and-exit mode), so multiple booting instances never race.
+- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types; repositories map them to `Sendable` value-type snapshots (e.g. `Example`) before returning, and the models themselves stay internal to the package.
+- **Readiness never throws.** `Probe` runs a `SELECT 1` — the cheapest statement both backends understand, independent of any schema — and maps every failure to `false`, so callers translate it straight into a readiness response.
+- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop so every query reaches the same in-memory database, rather than each pooled connection getting its own private one.
+- **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
+
+> **Note:** the `prefer` TLS posture is enforced by the driver itself: a supplied TLS configuration upgrades the connection only when the server advertises TLS, and continues in plaintext otherwise (pinned by a test against a fake server that offers no TLS). `require` currently maps to the same configuration and therefore behaves like `prefer` — the refusal when the server offers no TLS is not yet enforced.
+
+## Layout
+Sources are split by visibility, then by kind, one type per file:
+```
+Sources/
+├── Public/
+│ ├── Enumerations/ Driver, TLS
+│ ├── Methods/ Service, PrepareDB, Probe
+│ └── Types/ Configuration
+└── Internal/
+ ├── Migrations/ CreateExampleRecord
+ ├── Models/ ExampleRecord
+ └── Repositories/ ExampleRepository (returning the Example snapshot)
+Tests/
+├── Cases/ the test suites, mirroring the Sources/ layout
+└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
+ plaintext-only fake MySQL server, and the suite Tag constants
+```
+
+## Testing
+The suite runs against the in-memory backend by default, so `swift test` needs no database. The MySQL/MariaDB integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`, `MYSQL_TEST_NAME`, `MYSQL_TEST_USERNAME`, and `MYSQL_TEST_PASSWORD`); it reverts its migrations afterwards, so the shared database is left as it was found:
+```sh
+# in-memory only
+swift test
+# or
+# with the local MariaDB up (make db-mount):
+MYSQL_TEST_HOST=127.0.0.1 swift test
+```
+
+Outside the application's service group, a built `Fluent` service must be shut down explicitly — even on failure — or its connection pool asserts on `deinit`; the suites' `do`/`catch` pattern around `fluent.shutdown()` is the shape to follow.
+
+Every suite carries a tag naming the kind of API it exercises — `.enumeration` or `.method`, 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`).
+- macOS 15, matching the sibling `Infrastructure` and `Localization` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- Package dependencies: `hummingbird-fluent`, `fluent-mysql-driver`, `fluent-sqlite-driver`, and `sql-kit`; the test target additionally depends on `mysql-nio` and `swift-nio` for the TLS fallback test.
diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
index c52b9ef..60f053b 100644
--- a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
+++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
@@ -1,20 +1,17 @@
/// 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.
+/// 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.
+ /// - 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.
+ /// Nothing is written to disk, and all data is lost when the service stops — intended for local development and tests.
case inMemory
}
diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
index 1efaa16..be6f492 100644
--- a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
+++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
@@ -2,9 +2,8 @@ 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``.
+/// 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.
@@ -14,6 +13,9 @@ public enum TLS: Sendable {
case prefer
/// Connect only over TLS, refusing the connection when the server offers none.
+ ///
+ /// - Important: the refusal is not yet enforced — until it is, `require` behaves like ``prefer`` and silently falls back to plaintext when the
+ /// server offers no TLS.
case require
}
@@ -24,18 +26,15 @@ 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``.
+ /// 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.
+ /// - Note: the driver gives a supplied configuration ``prefer`` semantics natively — it upgrades to TLS only when the server advertises support,
+ /// and continues in plaintext otherwise — so `prefer` is fully enforced. `require` maps to the same configuration and therefore currently
+ /// behaves like ``prefer``: the refusal when the server offers no TLS is not yet enforced.
var tlsConfiguration: TLSConfiguration? {
switch self {
- case .off:
- return nil
- case .prefer, .require:
- return .makeClientConfiguration()
+ case .off: nil
+ default: .makeClientConfiguration()
}
}
diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift
index 43283b3..932216e 100644
--- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift
+++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift
@@ -2,9 +2,9 @@ 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 {
+/// 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: Sendable {
// MARK: Initializers
@@ -15,9 +15,9 @@ public struct PrepareDB {
/// 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.
+ /// 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 {
diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift
index cdbda36..8abbc8b 100644
--- a/Packages/Persistence/Sources/Public/Methods/Probe.swift
+++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift
@@ -3,8 +3,8 @@ 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()`.
+/// 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
@@ -26,11 +26,10 @@ public struct Probe: Sendable {
/// 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.
+ /// 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 {
diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift
index 033adcf..a8953f2 100644
--- a/Packages/Persistence/Sources/Public/Methods/Service.swift
+++ b/Packages/Persistence/Sources/Public/Methods/Service.swift
@@ -5,8 +5,7 @@ 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()`.
+/// 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
@@ -35,10 +34,9 @@ public struct Service: Sendable {
/// 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.
+ /// 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(
@@ -63,8 +61,8 @@ public struct Service: Sendable {
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.
+ // 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,
diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift
index a60408e..8867228 100644
--- a/Packages/Persistence/Sources/Public/Types/Configuration.swift
+++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift
@@ -1,7 +1,6 @@
/// 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.
+/// 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
diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
index aa12a00..0cd7d4e 100644
--- a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
@@ -1,9 +1,16 @@
+import Logging
+import MySQLNIO
+import NIOCore
+import NIOPosix
import NIOSSL
import Testing
@testable import Persistence
-@Suite("TLS enumeration")
+@Suite(
+ "TLS enumeration",
+ .tags(.enumeration)
+)
struct TLSTests {
// MARK: Properties tests
@@ -25,4 +32,27 @@ struct TLSTests {
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
}
+ @Test
+ func `prefer falls back to plaintext when the server offers no TLS`() async throws {
+ // The fake server never advertises `CLIENT_SSL`, so this connection can only succeed by downgrading to
+ // plaintext — pinning the driver behavior the `prefer` posture relies on.
+ let server = try await PlaintextMySQLServer.start()
+ let tlsConfiguration = try #require(TLS.prefer.tlsConfiguration)
+ let connection = try await MySQLConnection.connect(
+ to: .init(ipAddress: "127.0.0.1", port: server.port),
+ username: "loud",
+ database: "loud",
+ tlsConfiguration: tlsConfiguration,
+ logger: Logger(label: "test"),
+ on: MultiThreadedEventLoopGroup.singleton.any()
+ ).get()
+
+ let isConnected = !connection.isClosed
+
+ try await connection.close().get()
+ try await server.stop()
+
+ #expect(isConnected)
+ }
+
}
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
index 92ff75e..6f44afa 100644
--- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
@@ -1,12 +1,14 @@
import FluentKit
import HummingbirdFluent
import Logging
-import NIOCore
import Testing
@testable import Persistence
-@Suite("Probe method")
+@Suite(
+ "Probe method",
+ .tags(.method)
+)
struct ProbeTests {
// MARK: Methods tests
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
index da8bef7..3bc57e5 100644
--- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
@@ -5,7 +5,10 @@ import Testing
@testable import Persistence
-@Suite("Service method")
+@Suite(
+ "Service method",
+ .tags(.method)
+)
struct ServiceTests {
// MARK: Methods tests
diff --git a/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift
new file mode 100644
index 0000000..bec1ff6
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift
@@ -0,0 +1,8 @@
+import Testing
+
+extension Tag {
+ /// Tests exercising an enumeration of the Persistence package.
+ @Tag static var enumeration: Tag
+ /// Tests exercising a method of the Persistence package.
+ @Tag static var method: Tag
+}
diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift
new file mode 100644
index 0000000..27973c7
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift
@@ -0,0 +1,162 @@
+import NIOCore
+import NIOPosix
+
+/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake.
+///
+/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and
+/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only
+/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
+/// downgrades to plaintext rather than refusing the connection.
+final class PlaintextMySQLServer {
+
+ // MARK: Properties
+
+ /// The port the server listens on, assigned by the system at bind time.
+ let port: Int
+
+ /// The listening channel the server accepts connections through.
+ private let channel: Channel
+
+ // MARK: Initializers
+
+ private init(
+ channel: Channel,
+ port: Int
+ ) {
+ self.channel = channel
+ self.port = port
+ }
+
+ // MARK: Functions
+
+ /// Starts a server on the loopback interface, on a system-assigned port.
+ /// - Returns: the running server, ready to be connected to at ``port``.
+ static func start() async throws -> PlaintextMySQLServer {
+ let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
+ .childChannelInitializer { channel in
+ channel.eventLoop.makeCompletedFuture {
+ try channel.pipeline.syncOperations.addHandler(Handler())
+ }
+ }
+ .bind(host: "127.0.0.1", port: 0)
+ .get()
+
+ guard let port = channel.localAddress?.port else {
+ throw ChannelError.unknownLocalAddress
+ }
+
+ return .init(
+ channel: channel,
+ port: port
+ )
+ }
+
+ /// Stops the server, closing its listening channel.
+ func stop() async throws {
+ try await channel.close().get()
+ }
+
+}
+
+// MARK: - Handlers
+
+private extension PlaintextMySQLServer {
+
+ /// Greets a freshly accepted connection, accepts whatever authentication response arrives,
+ /// and closes on anything after that (e.g. a `COM_QUIT`).
+ final class Handler: ChannelInboundHandler {
+
+ // MARK: Type aliases
+
+ typealias InboundIn = ByteBuffer
+ typealias OutboundOut = ByteBuffer
+
+ // MARK: Properties
+
+ /// Whether the client's handshake response has already been answered with an OK packet.
+ private var didAuthenticate = false
+
+ // MARK: Functions
+
+ func channelActive(context: ChannelHandlerContext) {
+ context.writeAndFlush(
+ wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)),
+ promise: nil
+ )
+ }
+
+ func channelRead(
+ context: ChannelHandlerContext,
+ data: NIOAny
+ ) {
+ guard didAuthenticate else {
+ didAuthenticate = true
+
+ context.writeAndFlush(
+ wrapOutboundOut(Self.ok(allocator: context.channel.allocator)),
+ promise: nil
+ )
+
+ return
+ }
+
+ context.close(promise: nil)
+ }
+
+ // MARK: Helpers
+
+ /// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet.
+ ///
+ /// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`,
+ /// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`,
+ /// so the client cannot upgrade the connection to TLS.
+ private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer {
+ var payload = allocator.buffer(capacity: 80)
+
+ payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version
+ payload.writeNullTerminatedString("8.0.0") // server version
+ payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id
+ payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1
+ payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler
+ payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION
+ payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8)
+ payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit)
+ payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH
+ payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length
+ payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved
+ payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2
+ payload.writeNullTerminatedString("mysql_native_password") // auth plugin name
+
+ return framed(payload, sequence: 0, allocator: allocator)
+ }
+
+ /// A bare OK packet, framed as the reply to the client's handshake response.
+ private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer {
+ var payload = allocator.buffer(capacity: 8)
+
+ payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings
+
+ return framed(payload, sequence: 2, allocator: allocator)
+ }
+
+ /// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte.
+ private static func framed(
+ _ payload: ByteBuffer,
+ sequence: UInt8,
+ allocator: ByteBufferAllocator
+ ) -> ByteBuffer {
+ var packet = allocator.buffer(capacity: payload.readableBytes + 4)
+ var payload = payload
+
+ packet.writeInteger(UInt8(payload.readableBytes & 0xff))
+ packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff))
+ packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff))
+ packet.writeInteger(sequence)
+ packet.writeBuffer(&payload)
+
+ return packet
+ }
+
+ }
+
+}
diff --git a/Packages/Utility/Package.swift b/Packages/Utility/Package.swift
new file mode 100644
index 0000000..574189c
--- /dev/null
+++ b/Packages/Utility/Package.swift
@@ -0,0 +1,31 @@
+// swift-tools-version: 6.3
+
+import PackageDescription
+
+let package = Package(
+ name: "Utility",
+ platforms: [
+ .macOS(.v15),
+ ],
+ products: [
+ .library(
+ name: "Utility",
+ targets: [
+ "Utility"
+ ]
+ )
+ ],
+ targets: [
+ .target(
+ name: "Utility",
+ path: "Sources",
+ ),
+ .testTarget(
+ name: "UtilityTests",
+ dependencies: [
+ .byName(name: "Utility")
+ ],
+ path: "Tests"
+ ),
+ ]
+)
diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md
new file mode 100644
index 0000000..168ee32
--- /dev/null
+++ b/Packages/Utility/README.md
@@ -0,0 +1,31 @@
+# Utility
+The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
+
+## Overview
+The package provides, grouped by role:
+| Role | Types |
+| --- | --- |
+| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
+
+## Design rules
+- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird).
+- **Method structs.** Helpers 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/
+ └── Methods/ NormalizeEmail
+Tests/
+├── Cases/ the test suites, mirroring the Sources/ layout
+└── Utils/ the suite Tag constants
+```
+
+## Testing
+Every suite carries a tag naming the kind of API it exercises — `.method`, 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`).
+- macOS 15, matching the sibling `Infrastructure`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- No package dependencies — Foundation only.
diff --git a/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift
new file mode 100644
index 0000000..6429a6c
--- /dev/null
+++ b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+/// A validator reducing a submitted email address to its canonical form.
+///
+/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can
+/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes
+/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address.
+public struct NormalizeEmail: Sendable {
+
+ // MARK: Initializers
+
+ /// Creates an email normalization method.
+ public init() {}
+
+ // MARK: Functions
+
+ /// Validates and normalizes a submitted email address.
+ /// - Parameter email: the submitted email address, if any.
+ /// - Returns: the normalized address, or `nil` when the submission is missing or invalid.
+ public func callAsFunction(
+ _ email: String?
+ ) -> String? {
+ guard
+ let email = email?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased(),
+ email.utf8.count <= Constant.Length.maxEmail,
+ !email.unicodeScalars.contains(where: {
+ $0.properties.generalCategory == .control
+ }),
+ email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil
+ else {
+ return nil
+ }
+
+ return email
+ }
+
+}
+
+// MARK: - Constants
+
+private enum Constant {
+ /// A namespace for the length constants.
+ enum Length {
+ /// The longest an email address can be, in bytes, per RFC 5321's path limit.
+ static let maxEmail = 254
+ }
+}
diff --git a/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift
new file mode 100644
index 0000000..a26372e
--- /dev/null
+++ b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift
@@ -0,0 +1,57 @@
+import Testing
+
+@testable import Utility
+
+@Suite(
+ "NormalizeEmail method",
+ .tags(.method)
+)
+struct NormalizeEmailTests {
+
+ // MARK: Properties
+
+ private let normalize = NormalizeEmail()
+
+ // MARK: Functional tests
+
+ @Test(arguments: [
+ ("fan@loudmail.nl", "fan@loudmail.nl"),
+ ("Fan@LoudMail.NL", "fan@loudmail.nl"),
+ (" fan@loudmail.nl\n", "fan@loudmail.nl"),
+ ("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"),
+ // Exactly 254 bytes: the longest address the validator accepts.
+ (String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"),
+ ])
+ func `normalizes a valid address`(
+ submitted: String,
+ expected: String
+ ) {
+ #expect(normalize(submitted) == expected)
+ }
+
+ @Test(arguments: [
+ nil,
+ "",
+ " ",
+ "not-an-email",
+ "missing@dot",
+ "@loudmail.nl",
+ "fan@",
+ "fan@.nl",
+ "spaced out@loudmail.nl",
+ "fan@loud mail.nl",
+ // One byte over the 254-byte limit.
+ String(repeating: "a", count: 243) + "@loudmail.nl",
+ // Few enough characters, but multibyte ones put it over the byte limit.
+ String(repeating: "é", count: 130) + "@loudmail.nl",
+ // Control characters are not whitespace, so only the dedicated check catches them.
+ "fan\u{00}@loudmail.nl",
+ "fan@loudmail.nl\u{7F}",
+ ] as [String?])
+ func `rejects a missing or invalid address`(
+ submitted: String?
+ ) {
+ #expect(normalize(submitted) == nil)
+ }
+
+}
diff --git a/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift
new file mode 100644
index 0000000..877f053
--- /dev/null
+++ b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift
@@ -0,0 +1,6 @@
+import Testing
+
+extension Tag {
+ /// Tests exercising a method of the Utility package.
+ @Tag static var method: Tag
+}
diff --git a/Services/Website/.env.local b/Services/Website/.env.local
index 860ce02..1913441 100644
--- a/Services/Website/.env.local
+++ b/Services/Website/.env.local
@@ -1,10 +1,4 @@
-# Copy this file to `.env` and adjust values as needed.
-# cp .env.example .env
-#
-# Compose reads `.env` automatically to fill the ${VAR} placeholders in
-# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via
-# swift-configuration (allowMissing: true), so any extra app config keys placed
-# here are picked up by the running service too.
+# Local `.env` file used solely for Development purposes.
# --- Image / deployment -------------------------------------------------------
@@ -39,7 +33,7 @@ IMAGE_TAG=latest
HTTP_SERVER_NAME=SiteWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
-LOG_LEVEL=info
+LOG_LEVEL=debug
# --- Persistence ----------------------------------------------------------------
diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
index fe904d8..5ce7121 100644
--- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
+++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
@@ -50,7 +50,8 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
- useCustomWorkingDirectory = "NO"
+ useCustomWorkingDirectory = "YES"
+ customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile
index ec87365..0714bda 100644
--- a/Services/Website/Dockerfile
+++ b/Services/Website/Dockerfile
@@ -1,3 +1,32 @@
+# ================================
+# Asset image
+# ================================
+FROM node:22-alpine AS assets
+
+ARG ESBUILD_VERSION=0.28.1
+ARG OXIPNG_VERSION=9.1.5
+ARG SVGO_VERSION=4.0.2
+
+# Install the minifiers in their own layer, so they are cached across asset changes.
+# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base
+# image advances.
+RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
+ && npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
+
+# Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress the PNG images in place, keeping
+# their names so the URL paths derived from the StaticFile enumeration stay unchanged.
+WORKDIR /static
+COPY ./Services/Website/Resources/Static .
+RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \
+ && esbuild --minify --allow-overwrite --outdir=js js/*.js \
+ && oxipng --opt max --strip safe *.png \
+ && svgo --recursive --folder .
+
+# Export stage: `docker build --target assets-export --output ` writes the minified static files to for
+# local inspection.
+FROM scratch AS assets-export
+COPY --from=assets /static /
+
# ================================
# Build image
# ================================
@@ -14,16 +43,23 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
WORKDIR /build
# First just resolve dependencies.
-# This creates a cached layer that can be reused as long as the manifests do
-# 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.
+# This creates a cached layer that can be reused as long as the manifests do not change. The Website package depends on
+# the local Localization package via a relative path, so its manifest must be present for resolution to succeed.
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
+COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/
+COPY ./Packages/Utility/Package.swift ./Packages/Utility/
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
RUN swift package --package-path ./Services/Website resolve
-# Copy entire repo into container
-COPY . .
+# Copy only the Swift inputs needed for a release build. Static assets are built in the assets stage and copied into
+# staging after the binary is produced.
+COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources
+COPY ./Packages/Localization/Sources ./Packages/Localization/Sources
+COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
+COPY ./Packages/Utility/Sources ./Packages/Utility/Sources
+COPY ./Services/Website/Sources ./Services/Website/Sources
+COPY ./Services/Website/Tests ./Services/Website/Tests
# Build the application, with optimizations, with static linking, and using jemalloc
RUN swift build --package-path ./Services/Website -c release \
@@ -43,9 +79,12 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
-# Copy the static files directory (served by FileMiddleware) if it exists
+# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
+RUN mkdir -p ./Resources/Static
+COPY --from=assets /static ./Resources/Static
+
# Ensure that by default, neither the directory nor any of its contents are writable.
-RUN [ -d /build/Services/Website/Resources ] && { mv /build/Services/Website/Resources ./Resources && chmod -R a-w ./Resources; } || true
+RUN chmod -R a-w ./Resources
# ================================
# Run image
@@ -59,6 +98,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
+ curl \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
diff --git a/Services/Website/Makefile b/Services/Website/Makefile
index 49351b1..831c03f 100644
--- a/Services/Website/Makefile
+++ b/Services/Website/Makefile
@@ -105,6 +105,17 @@ db-reset: ## Stop and remove the local database instance and delete its data vol
--profile database down mariadb \
--volumes
+# --- Assets minification ------------------------------------------------------
+
+.PHONY: ast-minify
+ast-minify: ## Preview the minified JS/CSS assets in .build/minified
+ @docker build \
+ --target assets-export \
+ --output .build/minified \
+ --file Dockerfile \
+ ../..
+ @echo "Minified assets written to .build/minified"
+
# --- Registry deployment ------------------------------------------------------
.PHONY: img-check
diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift
index fdd8c5d..f3539f9 100644
--- a/Services/Website/Package.swift
+++ b/Services/Website/Package.swift
@@ -20,12 +20,18 @@ let package = Package(
)
],
dependencies: [
+ .package(
+ path: "../../Packages/Infrastructure"
+ ),
.package(
path: "../../Packages/Localization"
),
.package(
path: "../../Packages/Persistence"
),
+ .package(
+ path: "../../Packages/Utility"
+ ),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -55,6 +61,7 @@ let package = Package(
.executableTarget(
name: "Website",
dependencies: [
+ .byName(name: "Localization"),
.byName(name: "Persistence"),
.byName(name: "WebsiteLibrary"),
.product(
@@ -75,8 +82,10 @@ let package = Package(
.target(
name: "WebsiteLibrary",
dependencies: [
+ .byName(name: "Infrastructure"),
.byName(name: "Localization"),
.byName(name: "Persistence"),
+ .byName(name: "Utility"),
.product(
name: "Configuration",
package: "swift-configuration"
@@ -104,6 +113,7 @@ let package = Package(
.testTarget(
name: "WebsiteTests",
dependencies: [
+ .byName(name: "Infrastructure"),
.byName(name: "Website"),
.product(
name: "HummingbirdTesting",
@@ -115,6 +125,8 @@ let package = Package(
.testTarget(
name: "WebsiteLibraryTests",
dependencies: [
+ .byName(name: "Infrastructure"),
+ .byName(name: "Persistence"),
.byName(name: "WebsiteLibrary"),
.product(
name: "Elementary",
diff --git a/Services/Website/README.md b/Services/Website/README.md
index 2c67df3..18c0057 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -6,8 +6,8 @@ The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` 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 a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
-- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
-- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
+- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
+- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
@@ -22,24 +22,27 @@ Two SwiftPM targets:
| Target | Kind | Path | Role |
| --- | --- | --- | --- |
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
-| `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
+| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages, the `StaticFile` asset catalog, the request context, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
-The `Website` executable depends on two local packages:
+The `Website` executable depends on four local packages:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
+- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
- `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.
+- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
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:
```
LogRequestsMiddleware
- → SecurityHeadersMiddleware (security headers on every response)
- → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
- → LocalizationMiddleware (negotiates the request's language)
- → NotFoundMiddleware (renders the localized 404 page on .notFound)
- → FileMiddleware (serves Resources/Static)
-RootController (GET / → landing page)
-HealthController (GET /health → liveness, GET /health/ready → readiness)
+ → SecurityHeadersMiddleware (security headers on every response)
+ → VaryMiddleware (marks every response as varying on Accept-Encoding)
+ → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
+ → LocalizationMiddleware (negotiates the request's language)
+ → NotFoundMiddleware (renders the localized not-found page on .notFound)
+ → FileMiddleware (serves Resources/Static)
+RootController (GET / → landing page)
+HealthController (GET /health → liveness, GET /health/ready → readiness)
```
## Configuration
@@ -47,8 +50,13 @@ Configuration is read through [swift-configuration](https://github.com/apple/swi
the following sources, **highest precedence first**:
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
2. Process environment variables
-3. A `.env` file in the working directory (optional)
-4. Built-in defaults
+3. A `.env.local` file in the working directory (optional)
+4. A `.env` file in the working directory (optional)
+5. Built-in defaults
+
+The two files play different roles:
+- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
+- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
### Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
@@ -59,7 +67,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
### Static file caching
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
-| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for text assets (CSS, JS, plain text); also marked `must-revalidate`. |
+| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=`), so a deploy busts them by changing the URL. |
+| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for unversioned text assets (e.g. `robots.txt`); also marked `must-revalidate`. |
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). |
@@ -100,6 +109,14 @@ See [Persistence](#persistence-1) below for the workflow.
| --- | --- | --- | --- |
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
+### Rate limiting
+These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
+| Config key | Environment variable | Default | Description |
+| --- | --- | --- | --- |
+| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
+| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
+| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. |
+
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
@@ -119,6 +136,8 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
```
+A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
+
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
make pkg-build # swift build
@@ -158,13 +177,15 @@ DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
### 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.
+`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
+
## Testing
```sh
make pkg-test
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
```
-Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
+Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`.
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
@@ -192,6 +213,42 @@ docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
+### Static assets
+The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit:
+- CSS and JS are minified with [esbuild](https://esbuild.github.io).
+- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — the output is pixel-identical, only encoded smaller.
+- The SVG icon is minified with [svgo](https://github.com/svg/svgo).
+
+Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
+
+The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
+
+Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
+```sh
+make ast-minify
+```
+
+### Icons
+All icons are renditions of the star mark in `icon.svg`, which is the canonical source — there is no external design file to regenerate from.
+
+| File | Size | Used by | Dark mode |
+| --- | --- | --- | --- |
+| `icon.svg` | vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded `prefers-color-scheme` style flips the accent paths from `#000` to `#f3ecf5`. |
+| `favicon.ico` | 32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral **by design**: it is the orange star *without* the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
+| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
+| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
+
+The landing page pairs the icons with two `theme-color` metas: `#0c0710` (media-qualified for dark, listed first — the first matching entry wins) and `#fafafa` as the fallback.
+
+The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. for the 512px rendition:
+```sh
+docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
+ apk add --no-cache imagemagick librsvg oxipng &&
+ magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
+ oxipng --opt max --strip safe /work/icon-512.png'
+```
+(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
+
### Required variables
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
| Variable | Used for |
@@ -205,8 +262,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
| `LOG_LEVEL` | Runtime log level (default `info`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
| `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_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. |
| `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). |
+| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
diff --git a/Services/Website/Resources/Static/apple-touch-icon.png b/Services/Website/Resources/Static/apple-touch-icon.png
new file mode 100644
index 0000000..56d1ff2
Binary files /dev/null and b/Services/Website/Resources/Static/apple-touch-icon.png differ
diff --git a/Services/Website/Resources/Static/js/app.js b/Services/Website/Resources/Static/css/index.css
similarity index 100%
rename from Services/Website/Resources/Static/js/app.js
rename to Services/Website/Resources/Static/css/index.css
diff --git a/Services/Website/Resources/Static/css/error.css b/Services/Website/Resources/Static/css/not-found.css
similarity index 100%
rename from Services/Website/Resources/Static/css/error.css
rename to Services/Website/Resources/Static/css/not-found.css
diff --git a/Services/Website/Resources/Static/css/style.css b/Services/Website/Resources/Static/css/shared.css
similarity index 100%
rename from Services/Website/Resources/Static/css/style.css
rename to Services/Website/Resources/Static/css/shared.css
diff --git a/Services/Website/Resources/Static/favicon.ico b/Services/Website/Resources/Static/favicon.ico
index be74abd..2b5bcda 100644
Binary files a/Services/Website/Resources/Static/favicon.ico and b/Services/Website/Resources/Static/favicon.ico differ
diff --git a/Services/Website/Resources/Static/icon-192.png b/Services/Website/Resources/Static/icon-192.png
new file mode 100644
index 0000000..d94836c
Binary files /dev/null and b/Services/Website/Resources/Static/icon-192.png differ
diff --git a/Services/Website/Resources/Static/icon-512.png b/Services/Website/Resources/Static/icon-512.png
new file mode 100644
index 0000000..6412cb0
Binary files /dev/null and b/Services/Website/Resources/Static/icon-512.png differ
diff --git a/Services/Website/Resources/Static/icon.png b/Services/Website/Resources/Static/icon.png
deleted file mode 100644
index 8a42581..0000000
Binary files a/Services/Website/Resources/Static/icon.png and /dev/null differ
diff --git a/Services/Website/Resources/Static/icon.svg b/Services/Website/Resources/Static/icon.svg
index f232922..68e7d38 100644
--- a/Services/Website/Resources/Static/icon.svg
+++ b/Services/Website/Resources/Static/icon.svg
@@ -1 +1 @@
-
+
diff --git a/Services/Website/Resources/Static/js/index.js b/Services/Website/Resources/Static/js/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/Services/Website/Resources/Static/js/not-found.js b/Services/Website/Resources/Static/js/not-found.js
new file mode 100644
index 0000000..e69de29
diff --git a/Services/Website/Resources/Static/js/shared.js b/Services/Website/Resources/Static/js/shared.js
new file mode 100644
index 0000000..e69de29
diff --git a/Services/Website/Resources/Static/robots.txt b/Services/Website/Resources/Static/robots.txt
index 51d2d2e..e7c9f38 100644
--- a/Services/Website/Resources/Static/robots.txt
+++ b/Services/Website/Resources/Static/robots.txt
@@ -3,3 +3,5 @@
# Allow crawling of all content
User-agent: *
Disallow:
+
+Sitemap: https://loud.amsterdam/sitemap.xml
diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest
index 222ae16..e5e517f 100644
--- a/Services/Website/Resources/Static/site.webmanifest
+++ b/Services/Website/Resources/Static/site.webmanifest
@@ -2,9 +2,13 @@
"short_name": "",
"name": "",
"icons": [{
- "src": "icon.png",
+ "src": "icon-192.png",
"type": "image/png",
"sizes": "192x192"
+ }, {
+ "src": "icon-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
}],
"start_url": "/?utm_source=homescreen",
"background_color": "#fafafa",
diff --git a/Services/Website/Resources/Static/sitemap.xml b/Services/Website/Resources/Static/sitemap.xml
new file mode 100644
index 0000000..8bfaeae
--- /dev/null
+++ b/Services/Website/Resources/Static/sitemap.xml
@@ -0,0 +1,6 @@
+
+
+
+ https://loud.amsterdam/
+
+
diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift
index 4736863..d75a38f 100644
--- a/Services/Website/Sources/App/App.swift
+++ b/Services/Website/Sources/App/App.swift
@@ -1,6 +1,5 @@
import Configuration
import Hummingbird
-import Logging
/// The entry point of the website executable.
///
@@ -12,12 +11,16 @@ 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).
+ /// `.env.local` file when one is present, then a `.env` file when one is present, and finally the in-memory defaults.
static func main() async throws {
let reader = try await ConfigReader(
providers: [
CommandLineArgumentsProvider(),
EnvironmentVariablesProvider(),
+ EnvironmentVariablesProvider(
+ environmentFilePath: ".env.local",
+ allowMissing: true
+ ),
EnvironmentVariablesProvider(
environmentFilePath: ".env",
allowMissing: true
@@ -28,9 +31,9 @@ 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.
+ // 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
diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift
index dad5ec7..2164f29 100644
--- a/Services/Website/Sources/App/Extensions/App+Build.swift
+++ b/Services/Website/Sources/App/Extensions/App+Build.swift
@@ -1,14 +1,17 @@
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 also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
+/// 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 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.
@@ -16,15 +19,27 @@ import WebsiteLibrary
func application(
reader: ConfigReader
) async -> 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 persistence = Service(
driver: reader.driver,
logger: logger
)
let fluent = persistence()
+
+ let fingerprintAssets = FingerprintAssets(logger: logger)
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
@@ -32,8 +47,10 @@ func application(
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
+ assetVersion: fingerprintAssets(reader.staticFilesPath),
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
+ rateLimit: reader.rateLimit,
securityHeaders: reader.securityHeaders,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
@@ -46,8 +63,8 @@ func application(
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.
+ // 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()
@@ -116,50 +133,67 @@ private func 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.
+/// 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, 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.
+/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
+/// - rateLimit: the rate limit applied to the subscription endpoint.
/// - 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,
+ assetVersion: String?,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
+ rateLimit: RateLimitMiddleware.Configuration,
securityHeaders: SecurityHeadersMiddleware.Configuration,
logLevel: Logger.Level,
probe: Probe
) -> Router {
- let router = Router(context: AppRequestContext.self)
+ // 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
)
+ VaryMiddleware()
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
- NotFoundMiddleware()
+ NotFoundMiddleware(
+ assetVersion: assetVersion
+ )
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
- router.addRoutes {
- RootController().routes
- HealthController(probe: probe).routes
+ router.addController {
+ RootController(
+ assetVersion: assetVersion
+ )
+ HealthController(
+ probe: probe
+ )
}
return router
diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
index 2f9fdc2..c596d3f 100644
--- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
+++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
@@ -1,5 +1,6 @@
import Configuration
import Hummingbird
+import Infrastructure
import Logging
import Persistence
import WebsiteLibrary
@@ -15,9 +16,16 @@ package extension ConfigReader {
/// 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.
+ /// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
+ /// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and
+ /// fonts are immutable subset files, so all three are served long-lived and `immutable` — a deploy busts them by changing the URL, never by
+ /// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and
+ /// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category.
var cacheControl: CacheControl {
+ let maxAgeAsset = int(
+ forKey: .Cache.maxAgeAsset,
+ default: .Cache.maxAgeAsset
+ )
let maxAgeDefault = int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
@@ -32,6 +40,9 @@ package extension ConfigReader {
)
return .init([
+ (.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
+ (.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
+ (.font, [.public, .maxAge(maxAgeAsset), .immutable]),
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
(.image, [.public, .maxAge(maxAgeImage)]),
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
@@ -110,6 +121,28 @@ package extension ConfigReader {
)
}
+ /// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
+ ///
+ /// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set,
+ /// clients are keyed by the first `X-Forwarded-For` entry — enable it only behind a reverse proxy that sets the header, since clients can forge it
+ /// otherwise.
+ var rateLimit: RateLimitMiddleware.Configuration {
+ .init(
+ limit: int(
+ forKey: .RateLimit.limit,
+ default: .RateLimit.limit
+ ),
+ window: .seconds(int(
+ forKey: .RateLimit.window,
+ default: .RateLimit.window
+ )),
+ trustForwardedFor: bool(
+ forKey: .RateLimit.trustForwardedFor,
+ 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`
diff --git a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings
index 9a6ccec..729eda6 100644
--- a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings
+++ b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings
@@ -1,39 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
- "error.heading" : {
- "comment" : "The not-found page's main heading.",
- "localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Page Not Found"
- }
- }
- }
- },
- "error.message" : {
- "comment" : "The not-found page's body text.",
- "localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Sorry, but the page you were trying to view does not exist."
- }
- }
- }
- },
- "error.title" : {
- "comment" : "The not-found page's document title.",
- "localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Page Not Found"
- }
- }
- }
- },
"index.greeting" : {
"comment" : "The landing page's greeting paragraph.",
"localizations" : {
@@ -55,6 +22,39 @@
}
}
}
+ },
+ "notFound.heading" : {
+ "comment" : "The not-found page's main heading.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Page Not Found"
+ }
+ }
+ }
+ },
+ "notFound.message" : {
+ "comment" : "The not-found page's body text.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sorry, but the page you were trying to view does not exist."
+ }
+ }
+ }
+ },
+ "notFound.title" : {
+ "comment" : "The not-found page's document title.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Page Not Found"
+ }
+ }
+ }
}
},
"version" : "1.0"
diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift
index ef81f21..945ca74 100644
--- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift
+++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift
@@ -1,46 +1,32 @@
+import Infrastructure
+
/// A static file shipped with the website service.
///
-/// Each case identifies a file stored under the static files root (the `Resources/Static`
-/// directory) and served by Hummingbird's `FileMiddleware` middleware.
-enum StaticFile: CaseIterable, Sendable {
- /// The `js/app.js` script.
- case appJS
- /// The `css/error.css` stylesheet for the not-found page.
- case errorCSS
+/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
+/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
+enum StaticFile: Asset, CaseIterable {
+ /// The `apple-touch-icon.png` icon.
+ case appleTouchIcon
/// The `favicon.ico` icon.
- case faviconICO
- /// The `icon.png` icon.
- case iconPNG
+ case favicon
/// The `icon.svg` icon.
- case iconSVG
+ case icon
+ /// The `icon-192.png` icon for the web manifest.
+ case icon192
+ /// The `icon-512.png` icon for the web manifest.
+ case icon512
+ /// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
+ case index
+ /// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
+ case notFound
/// The `robots.txt` crawler directives.
- case robotsTXT
+ case robots
+ /// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
+ case shared
/// The `site.webmanifest` web application manifest.
- case siteWebmanifest
- /// The `css/style.css` stylesheet.
- case styleCSS
-}
-
-// MARK: - Enumerations
-
-extension StaticFile {
- /// A file extension used by a ``StaticFile``.
- enum Extension: String, Sendable {
- /// A Cascading Style Sheets file.
- case css
- /// A JavaScript file.
- case js
- /// A Portable Network Graphics image.
- case png
- /// A Windows icon image.
- case ico
- /// A Scalable Vector Graphics image.
- case svg
- /// A plain text file.
- case txt
- /// A web application manifest file.
- case webmanifest
- }
+ case site
+ /// The `sitemap.xml` crawler sitemap.
+ case sitemap
}
// MARK: - Extensions
@@ -49,88 +35,37 @@ extension StaticFile {
// MARK: Computed
- /// The file's content type.
- var contentType: String {
- switch fileExtension {
- case .css: "text/css"
- case .js: "text/javascript"
- case .png: "image/png"
- case .ico: "image/vnd.microsoft.icon"
- case .svg: "image/svg+xml"
- case .txt: "text/plain"
- case .webmanifest: "application/manifest+json"
- }
- }
-
- /// The file's extension.
- var fileExtension: Extension {
+ /// The file extensions the file is available with.
+ var fileExtensions: [AssetExtension] {
switch self {
- case .errorCSS,
- .styleCSS: .css
- case .appJS: .js
- case .faviconICO: .ico
- case .iconPNG: .png
- case .iconSVG: .svg
- case .robotsTXT: .txt
- case .siteWebmanifest: .webmanifest
+ case .appleTouchIcon,
+ .icon192,
+ .icon512: [.png]
+ case .index,
+ .notFound,
+ .shared: [.css, .js]
+ case .favicon: [.ico]
+ case .icon: [.svg]
+ case .robots: [.txt]
+ case .site: [.webmanifest]
+ case .sitemap: [.xml]
}
}
/// The file's name, without extension.
var fileName: String {
switch self {
- case .appJS: "app"
- case .errorCSS: "error"
- case .faviconICO: "favicon"
- case .iconPNG,
- .iconSVG: "icon"
- case .robotsTXT: "robots"
- case .siteWebmanifest: "site"
- case .styleCSS: "style"
- }
- }
-
- /// The path relative to the static files root (e.g. `"css/style.css"`).
- ///
- /// This also matches the URL path the file is served at by `FileMiddleware`.
- var relativePath: String {
- let file = "\(fileName).\(fileExtension.rawValue)"
-
- return subdirectory
- .map { "\($0)/\(file)" } ?? file
- }
-
- // MARK: Methods
-
- /// Resolves the file's path against the given base directory.
- ///
- /// - Parameter basePath: the directory the static files are served from.
- /// - Returns: the path to the file, relative to the `basePath` path.
- func path(
- relativeTo basePath: String
- ) -> String {
- guard !basePath.isEmpty else {
- return relativePath
- }
-
- return "\(basePath)/\(relativePath)"
- }
-
-}
-
-// MARK: - Helpers
-
-private extension StaticFile {
-
- // MARK: Computed
-
- /// The sub-directory within the static root that holds the file, if any.
- var subdirectory: String? {
- switch self {
- case .appJS: "js"
- case .errorCSS,
- .styleCSS: "css"
- default: nil
+ case .appleTouchIcon: "apple-touch-icon"
+ case .favicon: "favicon"
+ case .icon: "icon"
+ case .icon192: "icon-192"
+ case .icon512: "icon-512"
+ case .index: "index"
+ case .notFound: "not-found"
+ case .robots: "robots"
+ case .shared: "shared"
+ case .site: "site"
+ case .sitemap: "sitemap"
}
}
diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift
new file mode 100644
index 0000000..f852a71
--- /dev/null
+++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift
@@ -0,0 +1,70 @@
+import Elementary
+import Foundation
+import Infrastructure
+import Localization
+
+/// The site-wide defaults shared by every page of the website.
+extension Page {
+
+ // MARK: Computed
+
+ /// The document language, derived from the page's locale and falling back to the default language.
+ var lang: String {
+ locale.language.languageCode?.identifier
+ ?? LanguageList().default
+ }
+
+ /// The icon, manifest, and theme colour metadata shared by every page of the website.
+ @HTMLBuilder
+ var metadata: some HTML {
+ link(
+ .rel(.icon),
+ .href(StaticFile.favicon.urlPath(
+ for: .ico,
+ version: assetVersion
+ )),
+ .custom(
+ name: "sizes",
+ value: "any"
+ )
+ )
+ link(
+ .rel(.icon),
+ .href(StaticFile.icon.urlPath(
+ for: .svg,
+ version: assetVersion
+ )),
+ .custom(
+ name: "type",
+ value: "image/svg+xml"
+ )
+ )
+ link(
+ .rel("apple-touch-icon"),
+ .href(StaticFile.appleTouchIcon.urlPath(
+ for: .png,
+ version: assetVersion
+ ))
+ )
+ link(
+ .rel("manifest"),
+ .href(StaticFile.site.urlPath(
+ for: .webmanifest,
+ version: assetVersion
+ ))
+ )
+ meta(
+ .name("theme-color"),
+ .content("#fafafa")
+ )
+ meta(
+ .name("theme-color"),
+ .content("#0c0710"),
+ .custom(
+ name: "media",
+ value: "(prefers-color-scheme: dark)"
+ )
+ )
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift
deleted file mode 100644
index 06f11b6..0000000
--- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift
+++ /dev/null
@@ -1,63 +0,0 @@
-import Elementary
-import Foundation
-import Localization
-
-/// The HTML page rendered for a not-found response, with its text localized to a given locale.
-struct ErrorPage: HTMLDocument, Sendable {
-
- // MARK: Properties
-
- /// The locale the page content is localized to.
- private let locale: Locale
-
- /// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
- private let localize: Localize
-
- // MARK: Initializers
-
- /// Creates a not-found page localized to the given locale.
- /// - Parameter locale: the locale the page content is localized to.
- init(
- locale: Locale
- ) {
- self.locale = locale
- self.localize = .init(bundle: .module)
- }
-
- // MARK: Document
-
- /// The page's content: a localized heading and explanatory message.
- var body: some HTML {
- h1 {
- localize("error.heading", locale: locale)
- }
- p {
- localize("error.message", locale: locale)
- }
- }
-
- /// The metadata and stylesheet link placed in the document head.
- var head: some HTML {
- meta(.charset(.utf8))
- meta(
- .name(.viewport),
- .content("width=device-width, initial-scale=1")
- )
- link(
- .rel(.stylesheet),
- .href("/css/error.css")
- )
- }
-
- /// The document language, derived from the page's locale and falling back to the default language.
- var lang: String {
- locale.language.languageCode?.identifier
- ?? LanguageList(bundle: .module).default
- }
-
- /// The localized document title.
- var title: String {
- localize("error.title", locale: locale)
- }
-
-}
diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
index 8d6c0c2..843d85a 100644
--- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
+++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
@@ -1,14 +1,18 @@
import Elementary
import Foundation
+import Infrastructure
import Localization
/// The website's landing page, with its text localized to a given locale.
-struct IndexPage: HTMLDocument, Sendable {
+struct IndexPage {
// MARK: Properties
+ /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
+ let assetVersion: String?
+
/// The locale the page content is localized to.
- private let locale: Locale
+ let locale: Locale
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize: Localize
@@ -16,72 +20,40 @@ struct IndexPage: HTMLDocument, Sendable {
// MARK: Initializers
/// Creates a landing page localized to the given locale.
- /// - Parameter locale: the locale the page content is localized to.
+ /// - Parameters:
+ /// - locale: the locale the page content is localized to.
+ /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
init(
- locale: Locale
+ locale: Locale,
+ assetVersion: String? = nil
) {
+ self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
}
- // MARK: Document
+}
- /// The page's content: a localized greeting followed by the app script.
- var body: some HTML {
+// MARK: - Page
+
+extension IndexPage: Page {
+
+ // MARK: Properties
+
+ var content: some HTML {
p {
localize("index.greeting", locale: locale)
}
- script(.src("/js/app.js")) {}
}
- /// The metadata, stylesheet, icon, and manifest links placed in the document head.
- var head: some HTML {
- meta(.charset(.utf8))
- meta(
- .name(.viewport),
- .content("width=device-width, initial-scale=1")
- )
- link(
- .rel(.stylesheet),
- .href("/css/style.css")
- )
- link(
- .rel(.icon),
- .href("/favicon.ico"),
- .custom(
- name: "sizes",
- value: "any"
- )
- )
- link(
- .rel(.icon),
- .href("/icon.svg"),
- .custom(
- name: "type",
- value: "image/svg+xml"
- )
- )
- link(
- .rel("apple-touch-icon"),
- .href("/icon.png")
- )
- link(
- .rel("manifest"),
- .href("/site.webmanifest")
- )
- meta(
- .name("theme-color"),
- .content("#fafafa")
- )
+ var scripts: [any Asset] {
+ [StaticFile.index, StaticFile.shared]
}
- /// The document language, derived from the page's locale and falling back to the default language.
- var lang: String {
- locale.language.languageCode?.identifier
- ?? LanguageList(bundle: .module).default
+ var stylesheets: [any Asset] {
+ [StaticFile.shared, StaticFile.index]
}
- /// The localized document title.
var title: String {
localize("index.title", locale: locale)
}
diff --git a/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift
new file mode 100644
index 0000000..f49730c
--- /dev/null
+++ b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift
@@ -0,0 +1,64 @@
+import Elementary
+import Foundation
+import Infrastructure
+import Localization
+
+/// The HTML page rendered for a not-found response, with its text localized to a given locale.
+struct NotFoundPage {
+
+ // MARK: Properties
+
+ /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
+ let assetVersion: String?
+
+ /// The locale the page content is localized to.
+ let locale: Locale
+
+ /// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
+ private let localize: Localize
+
+ // MARK: Initializers
+
+ /// Creates a not-found page localized to the given locale.
+ /// - Parameters:
+ /// - locale: the locale the page content is localized to.
+ /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
+ /// default) to leave them unversioned.
+ init(
+ locale: Locale,
+ assetVersion: String? = nil
+ ) {
+ self.assetVersion = assetVersion
+ self.locale = locale
+ self.localize = .init(bundle: .module)
+ }
+}
+
+// MARK: Page
+
+extension NotFoundPage: Page {
+
+ // MARK: Properties
+
+ var content: some HTML {
+ h1 {
+ localize("notFound.heading", locale: locale)
+ }
+ p {
+ localize("notFound.message", locale: locale)
+ }
+ }
+
+ var scripts: [any Asset] {
+ [StaticFile.notFound, StaticFile.shared]
+ }
+
+ var stylesheets: [any Asset] {
+ [StaticFile.shared, StaticFile.notFound]
+ }
+
+ var title: String {
+ localize("notFound.title", locale: locale)
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift
deleted file mode 100644
index f9da28a..0000000
--- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift
+++ /dev/null
@@ -1,72 +0,0 @@
-import Elementary
-import HTTPTypes
-import Hummingbird
-import NIOCore
-
-/// A pre-rendered HTTP response for a fully static HTML page.
-///
-/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those
-/// bytes — along with a fixed status and precomputed headers — instead of re-rendering. This suits
-/// pages whose markup never changes between requests, such as the landing page and the not-found
-/// page, avoiding a per-request Elementary render on hot paths.
-///
-/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
-///
-/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the
-/// response-compression middleware downstream treats it exactly as it would a freshly rendered page.
-struct CachedHTMLResponse: Sendable {
-
- // MARK: Properties
-
- /// The page rendered to bytes once.
- private let buffer: ByteBuffer
- /// The headers applied to every response, precomputed once.
- private let headers: HTTPFields
- /// The status applied to every response.
- private let status: HTTPResponse.Status
-
- // MARK: Initializers
-
- /// Renders the given document to bytes once.
- /// - Parameters:
- /// - status: the status applied to every response. Defaults to `.ok`.
- /// - additionalHeaders: extra headers merged onto every response, alongside the content type.
- /// Used to carry per-language signals such as `Content-Language` and `Vary`.
- /// - document: the static HTML document to render and cache.
- init(
- status: HTTPResponse.Status = .ok,
- additionalHeaders: HTTPFields = [:],
- document: some HTMLDocument
- ) {
- var headers: HTTPFields = [
- .contentType: "text/html; charset=utf-8"
- ]
-
- for field in additionalHeaders {
- headers[field.name] = field.value
- }
-
- self.status = status
- self.headers = headers
- self.buffer = .init(string: document.render())
- }
-
- // MARK: Methods
-
- /// Builds a response from the cached, pre-rendered bytes.
- ///
- /// Mirrors the `text/html; charset=utf-8` content type `HTMLResponse` produces, and leaves the
- /// `Content-Length` unset so small pages remain eligible for compression.
- /// - Returns: the response carrying the cached HTML body.
- func response() -> Response {
- Response(
- status: status,
- headers: headers,
- body: .init { [buffer] writer in
- try await writer.write(buffer)
- try await writer.finish(nil)
- }
- )
- }
-
-}
diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift
similarity index 50%
rename from Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift
rename to Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift
index 0172f98..5c8f037 100644
--- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift
+++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift
@@ -1,26 +1,12 @@
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.
-public protocol LocalizedRequestContext: RequestContext {
-
- // MARK: Properties
-
- /// The language identifier negotiated for the request.
- var language: String { get set }
-
-}
-
-// MARK: - Context
+import Infrastructure
+import NIOCore
/// The website's request context.
///
-/// Extends the core request storage with the negotiated language, defaulting to the default
-/// supported language until ``LocalizationMiddleware`` resolves it from the request.
-public struct WebsiteRequestContext: LocalizedRequestContext {
+/// Extends the core request storage with the negotiated language, defaulting to the default supported language until ``LocalizationMiddleware``
+/// resolves it from the request, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
+public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext {
// MARK: Properties
@@ -28,6 +14,8 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
public var coreContext: CoreRequestContextStorage
/// The language identifier negotiated for the request.
public var language: String
+ /// The address of the connected client, captured from the source channel.
+ public let remoteAddress: SocketAddress?
// MARK: Initializers
@@ -38,6 +26,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
) {
self.coreContext = .init(source: source)
self.language = .empty
+ self.remoteAddress = source.channel.remoteAddress
}
}
diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
index ed37869..b6043a0 100644
--- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
@@ -1,13 +1,16 @@
import Hummingbird
import NIOCore
import Persistence
+import Infrastructure
/// Serves the website's health-check routes.
///
-/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
+/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
-/// router.addRoutes(HealthController(probe: probe).routes)
+/// router.addController {
+/// HealthController(probe: probe)
+/// }
/// ```
///
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
@@ -15,7 +18,7 @@ import Persistence
/// 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: Sendable {
+public struct HealthController {
// MARK: Properties
@@ -25,21 +28,21 @@ public struct HealthController: Sendable {
// MARK: Initializers
/// Creates a health controller.
- /// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
- /// route is served.
+ /// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness route is served.
public init(
probe: Probe? = nil
) {
self.probe = probe
}
- // MARK: Computed
+}
+
+// MARK: - RouterController
+
+extension HealthController: RouterController {
+
+ // MARK: Properties
- /// The routes served by the controller.
- ///
- /// 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 {
let routes = RouteCollection(context: Context.self)
@@ -68,9 +71,8 @@ private extension HealthController {
/// Handles a request for the liveness check.
///
- /// Returns a constant JSON body built directly per request — the payload is a tiny literal with no
- /// rendering step, so there is nothing to pre-render or cache. It reports only that the process is up,
- /// with no dependency check, so an orchestrator restarts the process only when the process itself is
+ /// 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. It reports only that the process is up, with no dependency check, so an orchestrator restarts the process only when the process itself is
/// unresponsive.
/// - Parameters:
/// - request: the incoming request.
@@ -89,9 +91,8 @@ private extension HealthController {
/// 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.
+ /// 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.
diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
index d457c73..b1a440f 100644
--- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
@@ -1,17 +1,19 @@
+import Foundation
import Hummingbird
+import Infrastructure
/// Serves the website's root routes.
///
-/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
-/// (or a sub-group) by the application that composes it:
+/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
-/// router.addRoutes(RootController().routes)
+/// router.addController {
+/// RootController()
+/// }
/// ```
///
-/// - 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 RootController: Sendable {
+/// - 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 RootController {
// MARK: Properties
@@ -21,16 +23,26 @@ public struct RootController: Sendable {
// MARK: Initializers
/// Creates a root controller.
- public init() {
- self.responses = .init { IndexPage(locale: $0) }
+ /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
+ public init(
+ assetVersion: String? = nil
+ ) {
+ self.responses = .init(bundle: .module) {
+ IndexPage(
+ locale: $0,
+ assetVersion: assetVersion
+ )
+ }
}
- // MARK: Computed
+}
+
+// MARK: - RouteController
+
+extension RootController: RouterController {
+
+ // MARK: Properties
- /// The routes served by the controller.
- ///
- /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the
- /// language negotiated for the request.
public var routes: RouteCollection {
let routes = RouteCollection(context: Context.self)
@@ -52,8 +64,7 @@ private extension RootController {
/// Handles a request for the landing page.
///
- /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``,
- /// falling back to the default language.
+ /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, falling back to the default language.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
@@ -63,7 +74,10 @@ private extension RootController {
request: Request,
context: Context
) -> Response {
- responses.response(for: context.language)
+ responses.response(
+ for: context.language,
+ request: request
+ )
}
}
diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
index 9e3ac7d..9e4a82e 100644
--- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
@@ -3,7 +3,9 @@ import Configuration
extension AbsoluteConfigKey {
/// A namespace for the static files cache configuration keys, as absolute keys.
public enum Cache {
- /// The absolute configuration key for the max-age, in seconds, applied to text-based static files.
+ /// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts.
+ public static let maxAgeAsset: AbsoluteConfigKey = .init(.Cache.maxAgeAsset)
+ /// The absolute configuration key for the max-age, in seconds, applied to unversioned text-based static files.
public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText)
/// The absolute configuration key for the max-age, in seconds, applied to image static files.
public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage)
@@ -50,6 +52,15 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the minimum log level.
public static let level: AbsoluteConfigKey = .init(.Log.level)
}
+ /// A namespace for the rate limit configuration keys, as absolute keys.
+ public enum RateLimit {
+ /// The absolute configuration key for the number of requests admitted per client per window.
+ public static let limit: AbsoluteConfigKey = .init(.RateLimit.limit)
+ /// The absolute configuration key for the window length, in seconds.
+ public static let window: AbsoluteConfigKey = .init(.RateLimit.window)
+ /// The absolute configuration key for keying clients by the first `X-Forwarded-For` entry.
+ public static let trustForwardedFor: AbsoluteConfigKey = .init(.RateLimit.trustForwardedFor)
+ }
/// A namespace for the path configuration keys, as absolute keys.
public enum Path {
/// The absolute configuration key for the directory the static files are served from.
diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
index cef6a42..289641c 100644
--- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
@@ -3,7 +3,9 @@ import Configuration
extension ConfigKey {
/// A namespace for the static files cache configuration keys.
public enum Cache {
- /// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
+ /// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts.
+ public static let maxAgeAsset: ConfigKey = "cache.maxAge.asset"
+ /// The configuration key for the max-age, in seconds, applied to unversioned text-based static files (e.g. plain text).
public static let maxAgeText: ConfigKey = "cache.maxAge.text"
/// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
public static let maxAgeImage: ConfigKey = "cache.maxAge.image"
@@ -50,6 +52,15 @@ extension ConfigKey {
/// The configuration key for the minimum log level.
public static let level: ConfigKey = "log.level"
}
+ /// A namespace for the rate limit configuration keys.
+ public enum RateLimit {
+ /// The configuration key for the number of requests admitted per client per window.
+ public static let limit: ConfigKey = "rateLimit.limit"
+ /// The configuration key for the window length, in seconds.
+ public static let window: ConfigKey = "rateLimit.window"
+ /// The configuration key for keying clients by the first `X-Forwarded-For` entry (enable only behind a trusted proxy).
+ public static let trustForwardedFor: ConfigKey = "rateLimit.trustForwardedFor"
+ }
/// A namespace for the path configuration keys.
public enum Path {
/// The configuration key for the directory the static files are served from.
diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
index 201068a..f1adf3f 100644
--- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
@@ -1,7 +1,9 @@
extension Int {
/// A namespace for the cache's default configuration values.
public enum Cache {
- /// The default max-age, in seconds, applied to text-based static files (1 hour).
+ /// The default max-age, in seconds, applied to fingerprinted assets and fonts (1 year).
+ public static let maxAgeAsset = 31_536_000
+ /// The default max-age, in seconds, applied to unversioned text-based static files (1 hour).
public static let maxAgeText = 3_600
/// The default max-age, in seconds, applied to image static files (1 week).
public static let maxAgeImage = 604_800
diff --git a/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift
new file mode 100644
index 0000000..49f9bdc
--- /dev/null
+++ b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift
@@ -0,0 +1,13 @@
+import Foundation
+import Localization
+
+public extension LanguageList {
+
+ // MARK: Initializers
+
+ /// Creates a language list backed by the module's String Catalog.
+ init() {
+ self.init(bundle: .module)
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift
new file mode 100644
index 0000000..009dc7b
--- /dev/null
+++ b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift
@@ -0,0 +1,13 @@
+import Foundation
+import Infrastructure
+
+public extension LocalizationMiddleware {
+
+ // MARK: Initializers
+
+ /// Creates a localization middleware that negotiates against the module's String Catalog languages.
+ init() {
+ self.init(bundle: .module)
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift
new file mode 100644
index 0000000..5c850f9
--- /dev/null
+++ b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift
@@ -0,0 +1,21 @@
+import Foundation
+import Infrastructure
+
+public extension NotFoundMiddleware {
+
+ // MARK: Initializers
+
+ /// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
+ /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
+ init(
+ assetVersion: String? = nil
+ ) {
+ self.init(bundle: .module) {
+ NotFoundPage(
+ locale: $0,
+ assetVersion: assetVersion
+ )
+ }
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift b/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift
deleted file mode 100644
index 038671d..0000000
--- a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-import Hummingbird
-
-/// A result builder that collects ``RouteCollection`` values into a stack.
-///
-/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route
-/// collections be listed declaratively rather than added one statement at a time.
-@resultBuilder
-public enum RouteCollectionBuilder {
-
- public static func buildExpression(
- _ collection: RouteCollection
- ) -> [RouteCollection] {
- [collection]
- }
-
- public static func buildBlock(
- _ collections: [RouteCollection]...
- ) -> [RouteCollection] {
- collections.flatMap { $0 }
- }
-
- public static func buildOptional(
- _ collections: [RouteCollection]?
- ) -> [RouteCollection] {
- collections ?? []
- }
-
- public static func buildEither(
- first collections: [RouteCollection]
- ) -> [RouteCollection] {
- collections
- }
-
- public static func buildEither(
- second collections: [RouteCollection]
- ) -> [RouteCollection] {
- collections
- }
-
- public static func buildArray(
- _ collections: [[RouteCollection]]
- ) -> [RouteCollection] {
- collections.flatMap { $0 }
- }
-
-}
-
-// MARK: - Helpers
-
-public extension RouterMethods {
-
- // MARK: Methods
-
- /// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder.
- ///
- /// Mirrors `addMiddleware`, letting controllers be listed declaratively:
- ///
- /// ```swift
- /// router.addRoutes {
- /// RootController().routes
- /// HealthController().routes
- /// }
- /// ```
- ///
- /// Each collection is added at the router's root, exactly as a sequence of
- /// `addRoutes(_:)` calls would.
- /// - Parameter build: the route-collection stack result builder.
- /// - Returns: the router, so calls can be chained.
- @discardableResult
- func addRoutes(
- @RouteCollectionBuilder _ build: () -> [RouteCollection]
- ) -> Self {
- for collection in build() {
- addRoutes(collection)
- }
-
- return self
- }
-
-}
diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
index eedb8f0..1e60acb 100644
--- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
@@ -23,27 +23,6 @@ extension String {
/// The directory, relative to the working directory, that the website's static files are served from.
public static let staticResources = "Resources/Static"
}
- /// 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.
- 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'`). Both pages link external stylesheets, so no inline-style
- /// exception is required.
- 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"
- /// The default `X-Frame-Options` (forbids framing the page).
- public static let frameOptions = "DENY"
- /// The default `Referrer-Policy`.
- public static let referrerPolicy = "strict-origin-when-cross-origin"
- /// The default `Permissions-Policy` (denies access to powerful browser features the site does not use).
- public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
- }
/// A namespace for the server string constants.
public enum Server {
/// The website server's name.
diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift
index a677a32..846ccf2 100644
--- a/Services/Website/Tests/App/AppTests.swift
+++ b/Services/Website/Tests/App/AppTests.swift
@@ -2,7 +2,7 @@ import Configuration
import Foundation
import Hummingbird
import HummingbirdTesting
-import Logging
+import Infrastructure
import NIOCore
import Testing
@@ -13,11 +13,11 @@ import Testing
struct AppTests {
// MARK: Constants
-
- private let textExtensions: [StaticFile.Extension] = [
+
+ // Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
+ private let immutableExtensions: [AssetExtension] = [
.css,
- .js,
- .txt
+ .js
]
// Absolute path to the package's "Resources/Static" folder, derived from this
@@ -49,6 +49,22 @@ struct AppTests {
}
}
+ @Test
+ func `landing page to answer a head request`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .head
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == "text/html; charset=utf-8")
+ #expect(response.body.readableBytes == 0)
+ }
+ }
+ }
+
@Test
func `health check to be served at the health path`() async throws {
try await app(
@@ -91,24 +107,103 @@ struct AppTests {
func `static files to be served`(
staticFile file: StaticFile
) async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ for fileExtension in file.fileExtensions {
+ try await client.execute(
+ uri: "/\(file.relativePath(for: fileExtension))",
+ method: .get
+ ) { response in
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == fileExtension.contentType)
+
+ let cacheControl = try #require(response.headers[.cacheControl])
+
+ #expect(cacheControl.contains("public") == true)
+ #expect(cacheControl.contains("max-age=") == true)
+
+ if immutableExtensions.contains(fileExtension) {
+ #expect(cacheControl.contains("immutable") == true)
+ } else if fileExtension == .txt {
+ #expect(cacheControl.contains("must-revalidate") == true)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func `versioned asset URL to be served`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
- uri: "/\(file.relativePath)",
+ uri: "/css/shared.css?v=0123456789abcdef",
method: .get
) { response in
#expect(response.status == .ok)
- #expect(response.headers[.contentType] == file.contentType)
-
- let cacheControl = try #require(response.headers[.cacheControl])
+ #expect(response.headers[.contentType] == "text/css")
+ }
+ }
+ }
- #expect(cacheControl.contains("public") == true)
- #expect(cacheControl.contains("max-age=") == true)
-
- if textExtensions.contains(file.fileExtension) {
- #expect(cacheControl.contains("must-revalidate") == true)
- }
+ @Test
+ func `landing page to reference fingerprinted assets`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains("/css/shared.css?v="))
+ #expect(body.contains("/js/shared.js?v="))
+ }
+ }
+ }
+
+ @Test
+ func `landing page to revalidate with an entity tag`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ let eTag = try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ #expect(response.headers[.cacheControl] == "public, no-cache")
+
+ return try #require(response.headers[.eTag])
+ }
+
+ try await client.execute(
+ uri: "/",
+ method: .get,
+ headers: [.ifNoneMatch: eTag]
+ ) { response in
+ #expect(response.status == .notModified)
+ #expect(response.body.readableBytes == 0)
+ #expect(response.headers[.eTag] == eTag)
+ }
+ }
+ }
+
+ @Test
+ func `responses to vary on language and encoding`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ let vary = try #require(response.headers[.vary])
+
+ #expect(vary.contains("Accept-Language"))
+ #expect(vary.contains("Accept-Encoding"))
}
}
}
@@ -162,6 +257,81 @@ struct AppTests {
}
}
+ @Test
+ func `error page to reference fingerprinted assets`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains("/css/not-found.css?v="))
+ #expect(body.contains("/js/shared.js?v="))
+ }
+ }
+ }
+
+ @Test
+ func `error page to be served without revalidation headers`() async throws {
+ // A `304 Not Modified` only ever stands in for a success, so the error page must not
+ // invite revalidation with an entity tag or a cache policy.
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ #expect(response.status == .notFound)
+ #expect(response.headers[.eTag] == nil)
+ #expect(response.headers[.cacheControl] == nil)
+ }
+ }
+ }
+
+ @Test
+ func `error page to vary on language and encoding`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/this-path-does-not-exist",
+ method: .get
+ ) { response in
+ let vary = try #require(response.headers[.vary])
+
+ #expect(vary.contains("Accept-Language"))
+ #expect(vary.contains("Accept-Encoding"))
+ }
+ }
+ }
+
+ @Test
+ func `landing page to revalidate a conditional head request`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ let eTag = try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ try #require(response.headers[.eTag])
+ }
+
+ try await client.execute(
+ uri: "/",
+ method: .head,
+ headers: [.ifNoneMatch: eTag]
+ ) { response in
+ #expect(response.status == .notModified)
+ #expect(response.body.readableBytes == 0)
+ }
+ }
+ }
+
@Test
func `security headers to be applied to the landing page`() async throws {
try await app(
diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift
index 110bca2..b675544 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift
@@ -1,40 +1,31 @@
-import Foundation
+import Infrastructure
import Testing
@testable import WebsiteLibrary
-@Suite("StaticFile enumeration")
+@Suite(
+ "StaticFile enumeration",
+ .tags(.enumeration)
+)
struct StaticFileTests {
-
+
// MARK: Type aliases
-
+
typealias File = StaticFile
- typealias FileExtension = StaticFile.Extension
-
+
// MARK: Computed tests
-
- @Test(arguments: zip(
- File.allCases,
- Self.contentTypes
- ))
- func `content type`(
- for file: File,
- expects contentType: String
- ) {
- #expect(file.contentType == contentType)
- }
-
+
@Test(arguments: zip(
File.allCases,
Self.fileExtensions
))
- func `file extension`(
+ func `file extensions`(
for file: File,
- expects `extension`: FileExtension
+ expects extensions: [AssetExtension]
) {
- #expect(file.fileExtension == `extension`)
+ #expect(file.fileExtensions == extensions)
}
-
+
@Test(arguments: zip(
File.allCases,
Self.fileNames
@@ -45,93 +36,47 @@ struct StaticFileTests {
) {
#expect(file.fileName == fileName)
}
-
- @Test(arguments: zip(
- File.allCases,
- Self.relativePaths
- ))
- func `relative path`(
- for file: File,
- expects relativePath: String
- ) {
- #expect(file.relativePath == relativePath)
- }
-
- // MARK: Method tests
-
- @Test(arguments: [
- "",
- ".",
- "Resources/Static"
- ])
- func `path relative to`(
- _ basePath: String
- ) {
- for file in File.allCases {
- let pathRelativeToBasePath = file.path(relativeTo: basePath)
-
- if basePath.isEmpty {
- #expect(pathRelativeToBasePath == file.relativePath)
- } else {
- #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)")
- }
- }
- }
// MARK: CaseIterable tests
-
+
@Test
func `all cases`() {
- #expect(File.allCases.count == 8)
+ #expect(File.allCases.count == 11)
}
-
+
}
// MARK: - Helpers
private extension StaticFileTests {
-
+
// MARK: Constants
-
- static let contentTypes: [String] = [
- "text/javascript",
- "text/css",
- "image/vnd.microsoft.icon",
- "image/png",
- "image/svg+xml",
- "text/plain",
- "application/manifest+json",
- "text/css"
- ]
- static let fileExtensions: [FileExtension] = [
- .js,
- .css,
- .ico,
- .png,
- .svg,
- .txt,
- .webmanifest,
- .css
+
+ static let fileExtensions: [[AssetExtension]] = [
+ [.png],
+ [.ico],
+ [.svg],
+ [.png],
+ [.png],
+ [.css, .js],
+ [.css, .js],
+ [.txt],
+ [.css, .js],
+ [.webmanifest],
+ [.xml]
]
static let fileNames: [String] = [
- "app",
- "error",
+ "apple-touch-icon",
"favicon",
"icon",
- "icon",
+ "icon-192",
+ "icon-512",
+ "index",
+ "not-found",
"robots",
+ "shared",
"site",
- "style"
+ "sitemap"
]
- static let relativePaths: [String] = [
- "js/app.js",
- "css/error.css",
- "favicon.ico",
- "icon.png",
- "icon.svg",
- "robots.txt",
- "site.webmanifest",
- "css/style.css"
- ]
-
+
}
diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift
index 254b6e3..d06606a 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift
@@ -4,7 +4,10 @@ import Testing
@testable import WebsiteLibrary
-@Suite("IndexPage page")
+@Suite(
+ "IndexPage page",
+ .tags(.page)
+)
struct IndexPageTests {
// MARK: Functional tests
@@ -17,13 +20,30 @@ struct IndexPageTests {
#expect(html.contains(""))
#expect(html.contains(#"lang="en""#))
- #expect(html.contains("/css/style.css"))
+ #expect(html.contains("/css/shared.css"))
+ #expect(html.contains("/css/index.css"))
#expect(html.contains("/favicon.ico"))
#expect(html.contains("/icon.svg"))
- #expect(html.contains("/icon.png"))
+ #expect(html.contains("/apple-touch-icon.png"))
#expect(html.contains("/site.webmanifest"))
+ #expect(html.contains(#"media="(prefers-color-scheme: dark)""#))
#expect(html.contains("Hello world!"))
- #expect(html.contains("/js/app.js"))
+ #expect(html.contains("/js/shared.js"))
+ #expect(html.contains("/js/index.js"))
+ }
+
+ @Test
+ func `renders versioned asset URLs when given a version`() {
+ let html = IndexPage(
+ locale: .init(identifier: "en"),
+ assetVersion: "0123456789abcdef"
+ ).render()
+
+ #expect(html.contains("/css/shared.css?v=0123456789abcdef"))
+ #expect(html.contains("/css/index.css?v=0123456789abcdef"))
+ #expect(html.contains("/js/shared.js?v=0123456789abcdef"))
+ #expect(html.contains("/js/index.js?v=0123456789abcdef"))
+ #expect(html.contains("/favicon.ico?v=0123456789abcdef"))
}
}
diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift
similarity index 60%
rename from Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift
rename to Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift
index 7e03844..21786cd 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift
@@ -4,14 +4,17 @@ import Testing
@testable import WebsiteLibrary
-@Suite("ErrorPage page")
-struct ErrorPageTests {
+@Suite(
+ "NotFoundPage page",
+ .tags(.page)
+)
+struct NotFoundPageTests {
// MARK: Functional tests
@Test
func `renders its markup`() {
- let html = ErrorPage(
+ let html = NotFoundPage(
locale: .init(identifier: "en")
).render()
@@ -19,7 +22,10 @@ struct ErrorPageTests {
#expect(html.contains(#"lang="en""#))
#expect(html.contains("Page Not Found"))
#expect(html.contains("Sorry, but the page you were trying to view does not exist."))
- #expect(html.contains("/css/error.css"))
+ #expect(html.contains("/css/shared.css"))
+ #expect(html.contains("/css/not-found.css"))
+ #expect(html.contains("/js/not-found.js"))
+ #expect(html.contains("/js/shared.js"))
}
}
diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
index f27ec95..dcf3654 100644
--- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
+++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
@@ -7,7 +7,10 @@ import Testing
@testable import WebsiteLibrary
-@Suite("HealthController controller")
+@Suite(
+ "HealthController controller",
+ .tags(.controller)
+)
struct HealthControllerTests {
// MARK: Functional tests
diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift
index 9fb321f..a211e9b 100644
--- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift
+++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift
@@ -1,11 +1,15 @@
import Hummingbird
import HummingbirdTesting
+import Infrastructure
import NIOCore
import Testing
@testable import WebsiteLibrary
-@Suite("RootController controller")
+@Suite(
+ "RootController controller",
+ .tags(.controller)
+)
struct RootControllerTests {
// MARK: Constants
@@ -42,4 +46,117 @@ struct RootControllerTests {
}
}
+ @Test
+ func `serves the landing page with revalidation headers`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ let eTag = try #require(response.headers[.eTag])
+
+ #expect(eTag.hasPrefix(#"W/""#))
+ #expect(response.headers[.cacheControl] == "public, no-cache")
+ }
+ }
+ }
+
+ @Test
+ func `revalidates a matching conditional request with a 304`() async throws {
+ try await app.test(.router) { client in
+ let eTag = try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ try #require(response.headers[.eTag])
+ }
+
+ try await client.execute(
+ uri: "/",
+ method: .get,
+ headers: [.ifNoneMatch: eTag]
+ ) { response in
+ #expect(response.status == .notModified)
+ #expect(response.headers[.eTag] == eTag)
+ #expect(response.body.readableBytes == 0)
+ }
+ }
+ }
+
+ @Test
+ func `serves the full page to a non-matching conditional request`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get,
+ headers: [.ifNoneMatch: #"W/"0123456789abcdef""#]
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .ok)
+ #expect(body.contains("Hello world!"))
+ }
+ }
+ }
+
+ @Test
+ func `renders versioned asset URLs when given a version`() async throws {
+ try await app(
+ assetVersion: "0123456789abcdef"
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains("/css/index.css?v=0123456789abcdef"))
+ #expect(body.contains("/js/index.js?v=0123456789abcdef"))
+ }
+ }
+ }
+
+ @Test
+ func `renders unversioned asset URLs by default`() async throws {
+ try await app.test(.router) { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains(#"href="/css/index.css""#))
+ #expect(!body.contains("?v="))
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension RootControllerTests {
+
+ // MARK: Methods
+
+ /// Builds an application whose root controller appends the given version token to the landing
+ /// page's asset URLs.
+ /// - Parameter assetVersion: the version token appended to the page's asset URLs.
+ /// - Returns: the configured application.
+ func app(
+ assetVersion: String?
+ ) -> some ApplicationProtocol {
+ let router = Router(context: WebsiteRequestContext.self)
+
+ router.addMiddleware {
+ LocalizationMiddleware()
+ }
+
+ router.addRoutes(RootController(
+ assetVersion: assetVersion
+ ).routes)
+
+ return Application(router: router)
+ }
+
}
diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift
deleted file mode 100644
index 5aadb6b..0000000
--- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-import Hummingbird
-import HummingbirdTesting
-import NIOCore
-import Testing
-
-@testable import WebsiteLibrary
-
-@Suite("NotFoundMiddleware middleware")
-struct NotFoundMiddlewareTests {
-
- // MARK: Constants
-
- private let app: Application = .init(router: {
- let router = Router(context: WebsiteRequestContext.self)
-
- router.addMiddleware {
- LocalizationMiddleware()
- NotFoundMiddleware()
- }
-
- router.get("hello") { _, _ in
- "Hello!"
- }
-
- router.get("boom") { _, _ -> String in
- throw HTTPError(.badRequest)
- }
-
- return router
- }())
-
- // MARK: Functional tests
-
- @Test
- func `renders the error page for an unmatched request`() async throws {
- try await app.test(.router) { client in
- try await client.execute(
- uri: "/this-path-does-not-exist",
- method: .get
- ) { response in
- let body = String(buffer: response.body)
-
- #expect(response.status == .notFound)
- #expect(response.headers[.contentType] == "text/html; charset=utf-8")
- #expect(response.headers[.contentLanguage] == "en")
- #expect(response.headers[.vary] == "Accept-Language")
- #expect(body.contains("Page Not Found"))
- }
- }
- }
-
- @Test
- func `passes a matched response through untouched`() async throws {
- try await app.test(.router) { client in
- try await client.execute(
- uri: "/hello",
- method: .get
- ) { response in
- #expect(response.status == .ok)
- #expect(response.body == ByteBuffer(string: "Hello!"))
- }
- }
- }
-
- @Test
- func `rethrows a non-not-found error unchanged`() async throws {
- try await app.test(.router) { client in
- try await client.execute(
- uri: "/boom",
- method: .get
- ) { response in
- let body = String(buffer: response.body)
-
- #expect(response.status == .badRequest)
- #expect(!body.contains("Page Not Found"))
- }
- }
- }
-
-}
diff --git a/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift
new file mode 100644
index 0000000..63cb88f
--- /dev/null
+++ b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift
@@ -0,0 +1,10 @@
+import Testing
+
+extension Tag {
+ /// Tests exercising a controller of the Website library.
+ @Tag static var controller: Tag
+ /// Tests exercising an enumeration of the Website library.
+ @Tag static var enumeration: Tag
+ /// Tests exercising a page of the Website library.
+ @Tag static var page: Tag
+}
diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan
index 7b0061e..a16d3f0 100644
--- a/Services/Website/Tests/Website.xctestplan
+++ b/Services/Website/Tests/Website.xctestplan
@@ -45,6 +45,20 @@
"identifier" : "LocalizationTests",
"name" : "LocalizationTests"
}
+ },
+ {
+ "target" : {
+ "containerPath" : "container:..\/..\/Packages\/Infrastructure",
+ "identifier" : "InfrastructureTests",
+ "name" : "InfrastructureTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:..\/..\/Packages\/Utility",
+ "identifier" : "UtilityTests",
+ "name" : "UtilityTests"
+ }
}
],
"version" : 1
diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml
index e83e081..b047699 100644
--- a/Services/Website/docker-compose.override.yml
+++ b/Services/Website/docker-compose.override.yml
@@ -1,12 +1,12 @@
# Local development overrides.
-# Compose merges this file on top of docker-compose.yml automatically, so a
-# plain `docker compose up` builds from source instead of pulling a registry image:
+# Compose merges this file on top of docker-compose.yml automatically, so a plain `docker compose up` builds from
+# source instead of pulling a registry image:
#
# docker compose up --build # build locally and run
# docker compose up -d # reuse the last local build
#
-# It reuses the `image:` name from the base file, so the local build is tagged
-# the same way the production image would be.
+# It reuses the `image:` name from the base file, so the local build is tagged the same way the production image would
+# be.
services:
website:
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
@@ -20,8 +20,8 @@ services:
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:
+ # 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:
diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml
index 957c544..42422b8 100644
--- a/Services/Website/docker-compose.yml
+++ b/Services/Website/docker-compose.yml
@@ -6,9 +6,8 @@ name: site-platform
# docker compose -f docker-compose.yml pull
# docker compose -f docker-compose.yml up -d
#
-# The `-f docker-compose.yml` flag is important in production: it skips the
-# docker-compose.override.yml file, which Compose would otherwise merge in
-# automatically for local development.
+# The `-f docker-compose.yml` flag is important in production: it skips the docker-compose.override.yml file, which
+# Compose would otherwise merge in automatically for local development.
services:
website:
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
@@ -21,13 +20,18 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-SiteWebsite}
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.
+ # 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:-site}
DATABASE_USERNAME: ${DATABASE_USERNAME:-site}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
- DATABASE_TLS: ${DATABASE_TLS:-require}
+ DATABASE_TLS: ${DATABASE_TLS:-prefer}
+ healthcheck:
+ test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s