From a045a235196f9f7d0a1f4c78482cbeca9c5784b2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 00:47:49 +0000 Subject: [PATCH 01/19] Web support for the Website service (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to introduce a new Web Swift package that provides a reusable, declarative way to register route controllers on a Hummingbird router, then adopt it in the Website service. To provide further details about the work: * Web package * The `RouterController` protocol — a Sendable protocol to group controllers behind one `routes` property. * The `RouteCollectionBuilder` — a result builder that collects controllers' route collections into a stack, with full support for optionals, conditionals, and arrays. * The `addController(_:)` method — a `RouterMethods` extension letting controllers be listed declaratively and adding each one's routes at the router root. * Website service * Added Web as a dependency of the _WebsiteLibrary_ target. * Conformed the `RootController` and `HealthController`controllers to the `RouterController` protocol. * Updated the `App+Build` extension to to use the cleaner `router.addController { … }` instead. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/17 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcode/xcshareddata/xcschemes/Web.xcscheme | 77 +++++++++ Packages/Web/Package.swift | 47 ++++++ .../Builders/RouteCollectionBuilder.swift | 46 ++++++ .../RouterMethods+RouteCollections.swift | 34 ++++ .../Public/Protocols/RouterController.swift | 32 ++++ .../RouterMethods+RouteCollectionsTests.swift | 155 ++++++++++++++++++ .../Utils/Controllers/StubController.swift | 30 ++++ Services/Website/Package.swift | 6 + Services/Website/README.md | 18 +- .../Sources/App/Extensions/App+Build.swift | 8 +- .../Public/Controllers/HealthController.swift | 22 ++- .../Public/Controllers/RootController.swift | 23 ++- .../RouterMethods+RouteCollections.swift | 80 --------- Services/Website/Tests/Website.xctestplan | 7 + 14 files changed, 480 insertions(+), 105 deletions(-) create mode 100644 Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme create mode 100644 Packages/Web/Package.swift create mode 100644 Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift create mode 100644 Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift create mode 100644 Packages/Web/Sources/Public/Protocols/RouterController.swift create mode 100644 Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift create mode 100644 Packages/Web/Tests/Utils/Controllers/StubController.swift delete mode 100644 Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift diff --git a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme new file mode 100644 index 0000000..5212084 --- /dev/null +++ b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Web/Package.swift b/Packages/Web/Package.swift new file mode 100644 index 0000000..c0c94e8 --- /dev/null +++ b/Packages/Web/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Web", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Web", + targets: [ + "Web" + ] + ), + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird.git", + from: "2.25.0" + ), + ], + targets: [ + .target( + name: "Web", + dependencies: [ + .product( + name: "Hummingbird", + package: "hummingbird" + ), + ], + path: "Sources" + ), + .testTarget( + name: "WebTests", + dependencies: [ + .byName(name: "Web"), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + ], + path: "Tests" + ), + ] +) diff --git a/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift new file mode 100644 index 0000000..548bc46 --- /dev/null +++ b/Packages/Web/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/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift new file mode 100644 index 0000000..d753351 --- /dev/null +++ b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift @@ -0,0 +1,34 @@ +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/Web/Sources/Public/Protocols/RouterController.swift b/Packages/Web/Sources/Public/Protocols/RouterController.swift new file mode 100644 index 0000000..819ec82 --- /dev/null +++ b/Packages/Web/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/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift new file mode 100644 index 0000000..1808834 --- /dev/null +++ b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -0,0 +1,155 @@ +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Web + +@Suite("addController method") +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/Web/Tests/Utils/Controllers/StubController.swift b/Packages/Web/Tests/Utils/Controllers/StubController.swift new file mode 100644 index 0000000..75b7586 --- /dev/null +++ b/Packages/Web/Tests/Utils/Controllers/StubController.swift @@ -0,0 +1,30 @@ +import Hummingbird +import Web + +/// 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/Services/Website/Package.swift b/Services/Website/Package.swift index fdd8c5d..39c5aad 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -26,6 +26,9 @@ let package = Package( .package( path: "../../Packages/Persistence" ), + .package( + path: "../../Packages/Web" + ), .package( url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0" @@ -77,6 +80,7 @@ let package = Package( dependencies: [ .byName(name: "Localization"), .byName(name: "Persistence"), + .byName(name: "Web"), .product( name: "Configuration", package: "swift-configuration" @@ -115,6 +119,8 @@ let package = Package( .testTarget( name: "WebsiteLibraryTests", dependencies: [ + .byName(name: "Persistence"), + .byName(name: "Web"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/README.md b/Services/Website/README.md index c705916..4293024 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -5,6 +5,7 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi 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. +- Registers newsletter subscriptions at `POST /subscribe`: the landing page's form-encoded submission is validated and normalized, guarded by a hidden honeypot field against bots, and stored tagged with the request's negotiated language. - 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. @@ -24,8 +25,9 @@ Two SwiftPM targets: | `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. | -The `Website` executable depends on two local packages: +The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). +- `Web` (`Packages/Web`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). @@ -39,6 +41,7 @@ LogRequestsMiddleware → NotFoundMiddleware (renders the localized 404 page on .notFound) → FileMiddleware (serves Resources/Static) RootController (GET / → landing page) +SubscriptionController (POST /subscribe → newsletter subscription) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -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 overrides — the in-memory database and `debug` logging. 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`, @@ -119,6 +127,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 @@ -164,7 +174,7 @@ 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 `Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `WebTests`, `PersistenceTests`, and `LocalizationTests`. 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 diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index dad5ec7..2bb0d52 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -157,9 +157,11 @@ private func router( ) } - router.addRoutes { - RootController().routes - HealthController(probe: probe).routes + router.addController { + RootController() + HealthController( + probe: probe + ) } return router diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index ed37869..55f732e 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 Web /// 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 @@ -33,13 +36,14 @@ public struct HealthController: Sendable { 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) diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index d457c73..de32221 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,17 +1,20 @@ import Hummingbird +import Web /// 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 { +public struct RootController { // MARK: Properties @@ -25,12 +28,14 @@ public struct RootController: Sendable { self.responses = .init { IndexPage(locale: $0) } } - // 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) 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/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index 7b0061e..0a36976 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -45,6 +45,13 @@ "identifier" : "LocalizationTests", "name" : "LocalizationTests" } + }, + { + "target" : { + "containerPath" : "container:..\/..\/Packages\/Web", + "identifier" : "WebTests", + "name" : "WebTests" + } } ], "version" : 1 From daf275b121463bc11dfe129bf1f16e615cfdb258 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 02:08:59 +0000 Subject: [PATCH 02/19] Improved the static assets definitions in the Website service (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to overhaul the static asset definitions for the Website service. To provide further details about the work: * Website library * Overhauled the `StateFile` enumeration to reduce the number of cases to one case per logical file name, each exposing a `fileExtensions` list. * Wired everything into the pages with a consistent ordering convention: stylesheets load shared-first so the page sheet wins the CSS cascade; scripts load page-first with shared.js last. The error page also gained the shared stylesheet and its scripts; the index page gained its page CSS/JS and the new touch icon link. * Used the `StaticFile` enumeration as a single source of truth for every _href_/_src_ in the `IndexPage` and the `ErrorPage` pages, eliminating hardcoded asset paths. * Website service * Added new assets to the Resources folder: * `apple-touch-icon.png` * `css/index.css` * `js/index.js` * `js/error.js` * `sitemap.xml` * Renamed existing assets within the Resources folder: * `css/style.css` → `css/shared.css` * `js/app.js` → `js/shared.js` * Fixed the working-directory location for the scheme in the Xcode project. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/18 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcshareddata/xcschemes/Website.xcscheme | 3 +- .../Resources/Static/apple-touch-icon.png | Bin 0 -> 9066 bytes .../Static/{js/app.js => css/index.css} | 0 .../Static/css/{style.css => shared.css} | 0 Services/Website/Resources/Static/js/error.js | 0 Services/Website/Resources/Static/js/index.js | 0 .../Website/Resources/Static/js/shared.js | 0 Services/Website/Resources/Static/robots.txt | 2 + Services/Website/Resources/Static/sitemap.xml | 6 + .../Internal/Enumerations/StaticFile.swift | 189 ++++++++++-------- .../Library/Internal/Pages/ErrorPage.swift | 12 +- .../Library/Internal/Pages/IndexPage.swift | 19 +- Services/Website/Tests/App/AppTests.swift | 28 +-- .../Enumerations/StaticFileTests.swift | 181 +++++++++++------ .../Cases/Internal/Pages/ErrorPageTests.swift | 3 + .../Cases/Internal/Pages/IndexPageTests.swift | 8 +- 16 files changed, 279 insertions(+), 172 deletions(-) create mode 100644 Services/Website/Resources/Static/apple-touch-icon.png rename Services/Website/Resources/Static/{js/app.js => css/index.css} (100%) rename Services/Website/Resources/Static/css/{style.css => shared.css} (100%) create mode 100644 Services/Website/Resources/Static/js/error.js create mode 100644 Services/Website/Resources/Static/js/index.js create mode 100644 Services/Website/Resources/Static/js/shared.js create mode 100644 Services/Website/Resources/Static/sitemap.xml 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/Resources/Static/apple-touch-icon.png b/Services/Website/Resources/Static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..56d1ff21c4d3c8caf95f98823bb10fa6679fab1d GIT binary patch literal 9066 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 literal 0 HcmV?d00001 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/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/js/error.js b/Services/Website/Resources/Static/js/error.js new file mode 100644 index 0000000..e69de29 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/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/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/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index ef81f21..aed99f7 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,24 +1,27 @@ /// 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. +/// 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: CaseIterable, Sendable { - /// The `js/app.js` script. - case appJS - /// The `css/error.css` stylesheet for the not-found page. - case errorCSS + /// The `apple-touch-icon.png` icon. + case appleTouchIcon + /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. + case error /// The `favicon.ico` icon. - case faviconICO - /// The `icon.png` icon. - case iconPNG - /// The `icon.svg` icon. - case iconSVG + case favicon + /// The `icon.png` and `icon.svg` icons. + case icon + /// The `css/index.css` stylesheet and `js/index.js` script for the landing page. + case index /// 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 + case site + /// The `sitemap.xml` crawler sitemap. + case sitemap } // MARK: - Enumerations @@ -40,6 +43,8 @@ extension StaticFile { case txt /// A web application manifest file. case webmanifest + /// An Extensible Markup Language file. + case xml } } @@ -49,9 +54,91 @@ extension StaticFile { // MARK: Computed + /// The file extensions the file is available with. + var fileExtensions: [Extension] { + switch self { + case .appleTouchIcon: [.png] + case .error, + .index, + .shared: [.css, .js] + case .favicon: [.ico] + case .icon: [.png, .svg] + case .robots: [.txt] + case .site: [.webmanifest] + case .sitemap: [.xml] + } + } + + /// The file's name, without extension. + var fileName: String { + switch self { + case .appleTouchIcon: "apple-touch-icon" + case .error: "error" + case .favicon: "favicon" + case .icon: "icon" + case .index: "index" + case .robots: "robots" + case .shared: "shared" + case .site: "site" + case .sitemap: "sitemap" + } + } + + // MARK: Methods + + /// Resolves the file'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: Extension + ) -> String { + let relativePath = relativePath(for: fileExtension) + + guard !basePath.isEmpty else { + return relativePath + } + + return "\(basePath)/\(relativePath)" + } + + /// Resolves the file'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: Extension + ) -> String { + let file = "\(fileName).\(fileExtension.rawValue)" + + return fileExtension.subdirectory + .map { "\($0)/\(file)" } ?? file + } + + /// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`). + /// + /// - Parameter fileExtension: the extension of the file to resolve. + /// - Returns: the path to use in `href` and `src` attributes. + func urlPath( + for fileExtension: Extension + ) -> String { + "/\(relativePath(for: fileExtension))" + } + +} + +extension StaticFile.Extension { + + // MARK: Computed + /// The file's content type. var contentType: String { - switch fileExtension { + switch self { case .css: "text/css" case .js: "text/javascript" case .png: "image/png" @@ -59,77 +146,15 @@ extension StaticFile { case .svg: "image/svg+xml" case .txt: "text/plain" case .webmanifest: "application/manifest+json" + case .xml: "application/xml" } } - /// The file's extension. - var fileExtension: Extension { - switch self { - case .errorCSS, - .styleCSS: .css - case .appJS: .js - case .faviconICO: .ico - case .iconPNG: .png - case .iconSVG: .svg - case .robotsTXT: .txt - case .siteWebmanifest: .webmanifest - } - } - - /// 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. + /// The sub-directory within the static root that holds files with this extension, if any. var subdirectory: String? { switch self { - case .appJS: "js" - case .errorCSS, - .styleCSS: "css" + case .css: "css" + case .js: "js" default: nil } } diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 06f11b6..79a1f53 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -26,7 +26,7 @@ struct ErrorPage: HTMLDocument, Sendable { // MARK: Document - /// The page's content: a localized heading and explanatory message. + /// The page's content: a localized heading and explanatory message, followed by the error and shared scripts. var body: some HTML { h1 { localize("error.heading", locale: locale) @@ -34,9 +34,11 @@ struct ErrorPage: HTMLDocument, Sendable { p { localize("error.message", locale: locale) } + script(.src(StaticFile.error.urlPath(for: .js))) {} + script(.src(StaticFile.shared.urlPath(for: .js))) {} } - /// The metadata and stylesheet link placed in the document head. + /// The metadata and stylesheet links placed in the document head. var head: some HTML { meta(.charset(.utf8)) meta( @@ -45,7 +47,11 @@ struct ErrorPage: HTMLDocument, Sendable { ) link( .rel(.stylesheet), - .href("/css/error.css") + .href(StaticFile.shared.urlPath(for: .css)) + ) + link( + .rel(.stylesheet), + .href(StaticFile.error.urlPath(for: .css)) ) } diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 8d6c0c2..7f8bf19 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -26,12 +26,13 @@ struct IndexPage: HTMLDocument, Sendable { // MARK: Document - /// The page's content: a localized greeting followed by the app script. + /// The page's content: a localized greeting followed by the shared and index scripts. var body: some HTML { p { localize("index.greeting", locale: locale) } - script(.src("/js/app.js")) {} + script(.src(StaticFile.index.urlPath(for: .js))) {} + script(.src(StaticFile.shared.urlPath(for: .js))) {} } /// The metadata, stylesheet, icon, and manifest links placed in the document head. @@ -43,11 +44,15 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel(.stylesheet), - .href("/css/style.css") + .href(StaticFile.shared.urlPath(for: .css)) + ) + link( + .rel(.stylesheet), + .href(StaticFile.index.urlPath(for: .css)) ) link( .rel(.icon), - .href("/favicon.ico"), + .href(StaticFile.favicon.urlPath(for: .ico)), .custom( name: "sizes", value: "any" @@ -55,7 +60,7 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel(.icon), - .href("/icon.svg"), + .href(StaticFile.icon.urlPath(for: .svg)), .custom( name: "type", value: "image/svg+xml" @@ -63,11 +68,11 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel("apple-touch-icon"), - .href("/icon.png") + .href(StaticFile.appleTouchIcon.urlPath(for: .png)) ) link( .rel("manifest"), - .href("/site.webmanifest") + .href(StaticFile.site.urlPath(for: .webmanifest)) ) meta( .name("theme-color"), diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index a677a32..e665763 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -94,20 +94,22 @@ struct AppTests { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in - try await client.execute( - uri: "/\(file.relativePath)", - method: .get - ) { response in - #expect(response.status == .ok) - #expect(response.headers[.contentType] == file.contentType) - - let cacheControl = try #require(response.headers[.cacheControl]) + 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) - #expect(cacheControl.contains("public") == true) - #expect(cacheControl.contains("max-age=") == true) - - if textExtensions.contains(file.fileExtension) { - #expect(cacheControl.contains("must-revalidate") == true) + let cacheControl = try #require(response.headers[.cacheControl]) + + #expect(cacheControl.contains("public") == true) + #expect(cacheControl.contains("max-age=") == true) + + if textExtensions.contains(fileExtension) { + #expect(cacheControl.contains("must-revalidate") == true) + } } } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 110bca2..4cb579f 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -5,36 +5,25 @@ import Testing @Suite("StaticFile 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: [FileExtension] ) { - #expect(file.fileExtension == `extension`) + #expect(file.fileExtensions == extensions) } - + @Test(arguments: zip( File.allCases, Self.fileNames @@ -45,20 +34,57 @@ struct StaticFileTests { ) { #expect(file.fileName == fileName) } - + + @Test(arguments: zip( + Self.extensions, + Self.contentTypes + )) + func `content type`( + for fileExtension: FileExtension, + expects contentType: String + ) { + #expect(fileExtension.contentType == contentType) + } + + @Test(arguments: zip( + Self.extensions, + Self.subdirectories + )) + func `subdirectory`( + for fileExtension: FileExtension, + expects subdirectory: String? + ) { + #expect(fileExtension.subdirectory == subdirectory) + } + + // MARK: Method tests + @Test(arguments: zip( File.allCases, Self.relativePaths )) - func `relative path`( + func `relative path for`( for file: File, - expects relativePath: String + expects relativePaths: [String] ) { - #expect(file.relativePath == relativePath) + for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { + #expect(file.relativePath(for: fileExtension) == relativePath) + } } - - // MARK: Method tests - + + @Test(arguments: zip( + File.allCases, + Self.relativePaths + )) + func `url path for`( + for file: File, + expects relativePaths: [String] + ) { + for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { + #expect(file.urlPath(for: fileExtension) == "/\(relativePath)") + } + } + @Test(arguments: [ "", ".", @@ -68,70 +94,99 @@ struct StaticFileTests { _ basePath: String ) { for file in File.allCases { - let pathRelativeToBasePath = file.path(relativeTo: basePath) + for fileExtension in file.fileExtensions { + let pathRelativeToBasePath = file.path( + relativeTo: basePath, + for: fileExtension + ) + let relativePath = file.relativePath(for: fileExtension) - if basePath.isEmpty { - #expect(pathRelativeToBasePath == file.relativePath) - } else { - #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)") + if basePath.isEmpty { + #expect(pathRelativeToBasePath == relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") + } } } } // MARK: CaseIterable tests - + @Test func `all cases`() { - #expect(File.allCases.count == 8) + #expect(File.allCases.count == 9) } - + } // 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, + + static let extensions: [FileExtension] = [ .css, - .ico, + .js, .png, + .ico, .svg, .txt, .webmanifest, - .css + .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 + ] + static let fileExtensions: [[FileExtension]] = [ + [.png], + [.css, .js], + [.ico], + [.png, .svg], + [.css, .js], + [.txt], + [.css, .js], + [.webmanifest], + [.xml] ] static let fileNames: [String] = [ - "app", + "apple-touch-icon", "error", "favicon", "icon", - "icon", + "index", "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" + static let relativePaths: [[String]] = [ + ["apple-touch-icon.png"], + ["css/error.css", "js/error.js"], + ["favicon.ico"], + ["icon.png", "icon.svg"], + ["css/index.css", "js/index.js"], + ["robots.txt"], + ["css/shared.css", "js/shared.js"], + ["site.webmanifest"], + ["sitemap.xml"] ] - + } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index 7e03844..6a148e9 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -19,7 +19,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/shared.css")) #expect(html.contains("/css/error.css")) + #expect(html.contains("/js/error.js")) + #expect(html.contains("/js/shared.js")) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index 254b6e3..89dd952 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -17,13 +17,15 @@ 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("Hello world!")) - #expect(html.contains("/js/app.js")) + #expect(html.contains("/js/shared.js")) + #expect(html.contains("/js/index.js")) } } From 79ecf311f4ad6a08079a8de5751c2ca1830c7aad Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 02:28:05 +0000 Subject: [PATCH 03/19] Local environment configuration support for the Website service (#19) This PR contains the work done to amend the `.env.local` handling for the Website service, plus other small fixes. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/19 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/.env.local | 10 ++-------- Services/Website/Dockerfile | 1 + Services/Website/README.md | 4 +--- Services/Website/Sources/App/App.swift | 6 +++++- .../Library/Cases/Internal/Pages/ErrorPageTests.swift | 2 +- .../Library/Cases/Internal/Pages/IndexPageTests.swift | 2 +- .../Public/Controllers/HealthControllerTests.swift | 2 +- .../Cases/Public/Controllers/RootControllerTests.swift | 2 +- .../Middlewares/LocalizationMiddlewareTests.swift | 2 +- .../Public/Middlewares/NotFoundMiddlewareTests.swift | 2 +- .../Middlewares/SecurityHeadersMiddlewareTests.swift | 2 +- .../Tests/Library/Utils/Extensions/Tag+Constants.swift | 10 ++++++++++ 12 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift diff --git a/Services/Website/.env.local b/Services/Website/.env.local index 1987239..865548e 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=LoudWebsite # Log verbosity: trace | debug | info | notice | warning | error | critical -LOG_LEVEL=info +LOG_LEVEL=debug # --- Persistence ---------------------------------------------------------------- diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index ec87365..b2ed87f 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -19,6 +19,7 @@ WORKDIR /build # 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/Web/Package.swift ./Packages/Web/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve diff --git a/Services/Website/README.md b/Services/Website/README.md index 4293024..27d7fa6 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -5,7 +5,6 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi 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. -- Registers newsletter subscriptions at `POST /subscribe`: the landing page's form-encoded submission is validated and normalized, guarded by a hidden honeypot field against bots, and stored tagged with the request's negotiated language. - 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. @@ -41,7 +40,6 @@ LogRequestsMiddleware → NotFoundMiddleware (renders the localized 404 page on .notFound) → FileMiddleware (serves Resources/Static) RootController (GET / → landing page) -SubscriptionController (POST /subscribe → newsletter subscription) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -56,7 +54,7 @@ the following sources, **highest precedence first**: 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 overrides — the in-memory database and `debug` logging. 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. +- **`.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`, diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index 4736863..ee99ab9 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -12,12 +12,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 diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index 6a148e9..3268b64 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -4,7 +4,7 @@ import Testing @testable import WebsiteLibrary -@Suite("ErrorPage page") +@Suite("ErrorPage page", .tags(.page)) struct ErrorPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index 89dd952..a754d77 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -4,7 +4,7 @@ import Testing @testable import WebsiteLibrary -@Suite("IndexPage page") +@Suite("IndexPage page", .tags(.page)) struct IndexPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift index f27ec95..9d565bf 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift @@ -7,7 +7,7 @@ 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..54c3a4f 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -5,7 +5,7 @@ import Testing @testable import WebsiteLibrary -@Suite("RootController controller") +@Suite("RootController controller", .tags(.controller)) struct RootControllerTests { // MARK: Constants diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index 607bc3a..9b71671 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -6,7 +6,7 @@ import Testing @testable import WebsiteLibrary -@Suite("LocalizationMiddleware middleware") +@Suite("LocalizationMiddleware middleware", .tags(.middleware)) struct LocalizationMiddlewareTests { // MARK: Constants diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 5aadb6b..3cffe32 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -5,7 +5,7 @@ import Testing @testable import WebsiteLibrary -@Suite("NotFoundMiddleware middleware") +@Suite("NotFoundMiddleware middleware", .tags(.middleware)) struct NotFoundMiddlewareTests { // MARK: Constants diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index 224f303..e987adc 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -4,7 +4,7 @@ import Testing @testable import WebsiteLibrary -@Suite("SecurityHeadersMiddleware middleware") +@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) struct SecurityHeadersMiddlewareTests { // MARK: Functional tests 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..2dee5a2 --- /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 a middleware of the Website library. + @Tag static var middleware: Tag + /// Tests exercising a page of the Website library. + @Tag static var page: Tag +} From 698cd60521d5a840bef5fd20ca85879d3b228c3f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 03:02:17 +0000 Subject: [PATCH 04/19] Static asset magnification for the Website service (#20) This PR contains the work done for build-time optimization of the static assets served by the Website service. The production Docker image now ships minified JS/CSS and losslessly recompressed images, while the sources in the repository stay readable and un-minified. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/20 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Dockerfile | 31 ++++++++++++++++++++++++++++++- Services/Website/Makefile | 11 +++++++++++ Services/Website/README.md | 15 ++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index b2ed87f..6f3f1d4 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -1,3 +1,27 @@ +# ================================ +# Asset image +# ================================ +FROM node:22-alpine AS assets + +# Install the minifiers in their own layer, so they are cached across asset changes +RUN apk add --no-cache oxipng \ + && npm install --global esbuild svgo + +# 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 icon.svg + +# 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 # ================================ @@ -45,8 +69,13 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ 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 +RUN [ -d /build/Services/Website/Resources ] && mv /build/Services/Website/Resources ./Resources || true + +# Overwrite the static files with the minified copies from the assets stage +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 diff --git a/Services/Website/Makefile b/Services/Website/Makefile index 47d91c8..2ed486e 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/README.md b/Services/Website/README.md index 27d7fa6..5f85d3a 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -6,7 +6,7 @@ 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`. +- 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 HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file. - Compresses responses (gzip/deflate) above a configurable size when the client advertises support. - Stamps a hardened set of security headers on every response. @@ -200,6 +200,19 @@ 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: +- 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. + +Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`: +```sh +make ast-minify +``` + ### 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 | From 6bdc127057a426a03d70320f564d95acd84a4f09 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 03:35:50 +0000 Subject: [PATCH 05/19] Improvements to Icons for the Website service (#21) Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/21 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/README.md | 21 ++++++++++++++++++ Services/Website/Resources/Static/favicon.ico | Bin 766 -> 700 bytes .../Website/Resources/Static/icon-192.png | Bin 0 -> 4501 bytes .../Website/Resources/Static/icon-512.png | Bin 0 -> 13856 bytes Services/Website/Resources/Static/icon.png | Bin 4029 -> 0 bytes Services/Website/Resources/Static/icon.svg | 2 +- .../Website/Resources/Static/site.webmanifest | 6 ++++- .../Internal/Enumerations/StaticFile.swift | 14 +++++++++--- .../Library/Internal/Pages/IndexPage.swift | 8 +++++++ .../Enumerations/StaticFileTests.swift | 12 +++++++--- .../Cases/Internal/Pages/IndexPageTests.swift | 1 + 11 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 Services/Website/Resources/Static/icon-192.png create mode 100644 Services/Website/Resources/Static/icon-512.png delete mode 100644 Services/Website/Resources/Static/icon.png diff --git a/Services/Website/README.md b/Services/Website/README.md index 5f85d3a..7e410c7 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -213,6 +213,27 @@ Preview the optimized output locally — requires only Docker and writes to the 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 | diff --git a/Services/Website/Resources/Static/favicon.ico b/Services/Website/Resources/Static/favicon.ico index be74abd69ad6a32de7375df13cab9354798e328f..2b5bcda7da4eb605b59c7837ef35b2c7204f30cc 100644 GIT binary patch literal 700 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 L+n6>Qqmupy;%#;E literal 0 HcmV?d00001 diff --git a/Services/Website/Resources/Static/icon-512.png b/Services/Website/Resources/Static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..6412cb0e8bcc61ba7dc75c05103fa6ed9fc1f30b GIT binary patch literal 13856 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 + 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/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index aed99f7..69afb22 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -10,8 +10,12 @@ enum StaticFile: CaseIterable, Sendable { case error /// The `favicon.ico` icon. case favicon - /// The `icon.png` and `icon.svg` icons. + /// The `icon.svg` icon. 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 `robots.txt` crawler directives. @@ -57,12 +61,14 @@ extension StaticFile { /// The file extensions the file is available with. var fileExtensions: [Extension] { switch self { - case .appleTouchIcon: [.png] + case .appleTouchIcon, + .icon192, + .icon512: [.png] case .error, .index, .shared: [.css, .js] case .favicon: [.ico] - case .icon: [.png, .svg] + case .icon: [.svg] case .robots: [.txt] case .site: [.webmanifest] case .sitemap: [.xml] @@ -76,6 +82,8 @@ extension StaticFile { case .error: "error" case .favicon: "favicon" case .icon: "icon" + case .icon192: "icon-192" + case .icon512: "icon-512" case .index: "index" case .robots: "robots" case .shared: "shared" diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 7f8bf19..6a9b6e2 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -74,6 +74,14 @@ struct IndexPage: HTMLDocument, Sendable { .rel("manifest"), .href(StaticFile.site.urlPath(for: .webmanifest)) ) + meta( + .name("theme-color"), + .content("#0c0710"), + .custom( + name: "media", + value: "(prefers-color-scheme: dark)" + ) + ) meta( .name("theme-color"), .content("#fafafa") diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 4cb579f..24bde11 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -114,7 +114,7 @@ struct StaticFileTests { @Test func `all cases`() { - #expect(File.allCases.count == 9) + #expect(File.allCases.count == 11) } } @@ -159,7 +159,9 @@ private extension StaticFileTests { [.png], [.css, .js], [.ico], - [.png, .svg], + [.svg], + [.png], + [.png], [.css, .js], [.txt], [.css, .js], @@ -171,6 +173,8 @@ private extension StaticFileTests { "error", "favicon", "icon", + "icon-192", + "icon-512", "index", "robots", "shared", @@ -181,7 +185,9 @@ private extension StaticFileTests { ["apple-touch-icon.png"], ["css/error.css", "js/error.js"], ["favicon.ico"], - ["icon.png", "icon.svg"], + ["icon.svg"], + ["icon-192.png"], + ["icon-512.png"], ["css/index.css", "js/index.js"], ["robots.txt"], ["css/shared.css", "js/shared.js"], diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index a754d77..fc308d2 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -23,6 +23,7 @@ struct IndexPageTests { #expect(html.contains("/icon.svg")) #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/shared.js")) #expect(html.contains("/js/index.js")) From d0058dc636fa2656e45f4eebc52e4d9b52215846 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 08:56:35 +0000 Subject: [PATCH 06/19] Shared HTML template for the Website service (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to define a `Page` protocol that extracts the HTML scaffolding that `IndexPage` and `ErrorPage` pages duplicated, so every page of the website declares only what makes it unique — its content, title, and assets — while the document structure lives in one place. Also cleans up imports across the workspace. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/22 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Cases/Public/Methods/ProbeTests.swift | 1 - .../RouterMethods+RouteCollectionsTests.swift | 1 + Services/Website/Sources/App/App.swift | 1 - .../Sources/App/Extensions/App+Build.swift | 1 + .../Library/Internal/Pages/ErrorPage.swift | 40 +++---- .../Library/Internal/Pages/IndexPage.swift | 77 +++---------- .../Library/Internal/Protocols/Page.swift | 107 ++++++++++++++++++ .../Public/Controllers/RootController.swift | 4 +- Services/Website/Tests/App/AppTests.swift | 1 - .../Enumerations/StaticFileTests.swift | 1 - 10 files changed, 139 insertions(+), 95 deletions(-) create mode 100644 Services/Website/Sources/Library/Internal/Protocols/Page.swift diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift index 92ff75e..4458f0e 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -1,7 +1,6 @@ import FluentKit import HummingbirdFluent import Logging -import NIOCore import Testing @testable import Persistence diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 1808834..2f6edc5 100644 --- a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -1,5 +1,6 @@ import Hummingbird import HummingbirdTesting +import NIOCore import Testing @testable import Web diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index ee99ab9..679beff 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. /// diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 2bb0d52..1096740 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -3,6 +3,7 @@ import Hummingbird import HummingbirdCompression import Logging import Persistence +import Web import WebsiteLibrary /// Builds the website application. diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 79a1f53..3f332fb 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -3,12 +3,12 @@ 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 { +struct ErrorPage { // MARK: Properties /// 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 @@ -23,45 +23,31 @@ struct ErrorPage: HTMLDocument, Sendable { self.locale = locale self.localize = .init(bundle: .module) } +} - // MARK: Document +// MARK: Page - /// The page's content: a localized heading and explanatory message, followed by the error and shared scripts. - var body: some HTML { +extension ErrorPage: Page { + + // MARK: Properties + + var content: some HTML { h1 { localize("error.heading", locale: locale) } p { localize("error.message", locale: locale) } - script(.src(StaticFile.error.urlPath(for: .js))) {} - script(.src(StaticFile.shared.urlPath(for: .js))) {} } - /// The metadata and stylesheet 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(StaticFile.shared.urlPath(for: .css)) - ) - link( - .rel(.stylesheet), - .href(StaticFile.error.urlPath(for: .css)) - ) + var scripts: [StaticFile] { + [.error, .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: [StaticFile] { + [.shared, .error] } - /// 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 6a9b6e2..aabef0b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -3,12 +3,12 @@ import Foundation import Localization /// The website's landing page, with its text localized to a given locale. -struct IndexPage: HTMLDocument, Sendable { +struct IndexPage { // MARK: Properties /// 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 @@ -24,77 +24,28 @@ struct IndexPage: HTMLDocument, Sendable { self.localize = .init(bundle: .module) } - // MARK: Document +} - /// The page's content: a localized greeting followed by the shared and index scripts. - var body: some HTML { +// MARK: - Page + +extension IndexPage: Page { + + // MARK: Properties + + var content: some HTML { p { localize("index.greeting", locale: locale) } - script(.src(StaticFile.index.urlPath(for: .js))) {} - script(.src(StaticFile.shared.urlPath(for: .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(StaticFile.shared.urlPath(for: .css)) - ) - link( - .rel(.stylesheet), - .href(StaticFile.index.urlPath(for: .css)) - ) - link( - .rel(.icon), - .href(StaticFile.favicon.urlPath(for: .ico)), - .custom( - name: "sizes", - value: "any" - ) - ) - link( - .rel(.icon), - .href(StaticFile.icon.urlPath(for: .svg)), - .custom( - name: "type", - value: "image/svg+xml" - ) - ) - link( - .rel("apple-touch-icon"), - .href(StaticFile.appleTouchIcon.urlPath(for: .png)) - ) - link( - .rel("manifest"), - .href(StaticFile.site.urlPath(for: .webmanifest)) - ) - meta( - .name("theme-color"), - .content("#0c0710"), - .custom( - name: "media", - value: "(prefers-color-scheme: dark)" - ) - ) - meta( - .name("theme-color"), - .content("#fafafa") - ) + var scripts: [StaticFile] { + [.index, .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: [StaticFile] { + [.shared, .index] } - /// The localized document title. var title: String { localize("index.title", locale: locale) } diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift new file mode 100644 index 0000000..e05eb45 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Protocols/Page.swift @@ -0,0 +1,107 @@ +import Elementary +import Foundation +import Localization + +/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content. +/// +/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the +/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the +/// document language derived from the locale. +protocol Page: HTMLDocument, Sendable { + + // MARK: Associated types + + /// The type of the page's markup. + associatedtype Content: HTML + + // MARK: Properties + + /// 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 scripts loaded at the end of the document body, in order. + var scripts: [StaticFile] { get } + + /// The stylesheets linked in the document head, in order. + var stylesheets: [StaticFile] { get } + +} + +// MARK: - Implementations + +extension Page { + + // MARK: Computed + + /// The page ``content`` followed by its ``scripts``. + @HTMLBuilder + var body: some HTML { + content + for file in scripts { + script(.src(file.urlPath(for: .js))) {} + } + } + + /// The metadata, ``stylesheets``, icon, and manifest links placed in the document head. + @HTMLBuilder + var head: some HTML { + meta(.charset(.utf8)) + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath(for: .css)) + ) + } + link( + .rel(.icon), + .href(StaticFile.favicon.urlPath(for: .ico)), + .custom( + name: "sizes", + value: "any" + ) + ) + link( + .rel(.icon), + .href(StaticFile.icon.urlPath(for: .svg)), + .custom( + name: "type", + value: "image/svg+xml" + ) + ) + link( + .rel("apple-touch-icon"), + .href(StaticFile.appleTouchIcon.urlPath(for: .png)) + ) + link( + .rel("manifest"), + .href(StaticFile.site.urlPath(for: .webmanifest)) + ) + meta( + .name("theme-color"), + .content("#fafafa") + ) + meta( + .name("theme-color"), + .content("#0c0710"), + .custom( + name: "media", + value: "(prefers-color-scheme: dark)" + ) + ) + } + + /// 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 + } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index de32221..7a0dff0 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -25,7 +25,9 @@ public struct RootController { /// Creates a root controller. public init() { - self.responses = .init { IndexPage(locale: $0) } + self.responses = .init { + IndexPage(locale: $0) + } } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index e665763..23fe521 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -2,7 +2,6 @@ import Configuration import Foundation import Hummingbird import HummingbirdTesting -import Logging import NIOCore import Testing diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 24bde11..2ca9b20 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -1,4 +1,3 @@ -import Foundation import Testing @testable import WebsiteLibrary From 242cb92bc55e5d20dff2ef911239e835213cac7f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 18:23:46 +0000 Subject: [PATCH 07/19] Few small fixes for the Website service (#23) This PR contains the work done to address small fixes. To provide further details: * HTML template * Removed the duplicate charset tag from the Page protocol's head property. * Router * Enabled auto-generated HEAD endpoints so every GET route gets a HEAD sibling. Uptime monitors and crawlers probing with HEAD now receive the page's status and headers instead of a 404. * Docker * Pinned the asset optimizer versionsvia build args so minified output is reproducible for a given Dockerfile commit. * Narrowed the build context copied into the release stage, only package manifests and Swift sources are copied. Static assets come from the separate assets stage after the binary is built. * svgo now minifies all SVGs recursively rather than just icon.svg, and the staging step creates Resources/Static explicitly instead of conditionally moving the unminified sources. * Added curl to the runtime image (needed for the container healthcheck) and .claude to .dockerignore. * Docker-compose * Added a `healthcheck` to the website service hitting GET /health (liveness only), so Compose reports process health without coupling container health to database reachability. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/23 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .dockerignore | 3 +- Services/Website/Dockerfile | 31 +++++++++++++------ Services/Website/README.md | 6 +++- .../Sources/App/Extensions/App+Build.swift | 7 ++++- .../Library/Internal/Protocols/Page.swift | 4 ++- Services/Website/Tests/App/AppTests.swift | 16 ++++++++++ Services/Website/docker-compose.yml | 6 ++++ 7 files changed, 59 insertions(+), 14 deletions(-) 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/Services/Website/Dockerfile b/Services/Website/Dockerfile index 6f3f1d4..98ac38a 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -3,9 +3,15 @@ # ================================ FROM node:22-alpine AS assets -# Install the minifiers in their own layer, so they are cached across asset changes -RUN apk add --no-cache oxipng \ - && npm install --global esbuild svgo +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 @@ -15,7 +21,7 @@ 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 icon.svg + && svgo --recursive --folder . # Export stage: `docker build --target assets-export --output ` writes the # minified static files to for local inspection. @@ -47,8 +53,13 @@ COPY ./Packages/Web/Package.swift ./Packages/Web/ 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/Localization/Sources ./Packages/Localization/Sources +COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources +COPY ./Packages/Web/Sources ./Packages/Web/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 \ @@ -68,10 +79,9 @@ 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 -RUN [ -d /build/Services/Website/Resources ] && mv /build/Services/Website/Resources ./Resources || true - -# Overwrite the static files with the minified copies from the assets stage +# 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. @@ -89,6 +99,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/README.md b/Services/Website/README.md index 7e410c7..ccaa62c 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -166,6 +166,8 @@ 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 @@ -201,13 +203,15 @@ 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: +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 diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 1096740..45814b8 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -140,7 +140,12 @@ private func router( 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) diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift index e05eb45..e8bc515 100644 --- a/Services/Website/Sources/Library/Internal/Protocols/Page.swift +++ b/Services/Website/Sources/Library/Internal/Protocols/Page.swift @@ -47,9 +47,11 @@ extension Page { } /// The metadata, ``stylesheets``, icon, and manifest 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. @HTMLBuilder var head: some HTML { - meta(.charset(.utf8)) meta( .name(.viewport), .content("width=device-width, initial-scale=1") diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 23fe521..1b45edb 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -48,6 +48,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( diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index 81c40d5..0aa0ffb 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -31,3 +31,9 @@ services: DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams} DATABASE_PASSWORD: ${DATABASE_PASSWORD:-} DATABASE_TLS: ${DATABASE_TLS:-require} + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s From a8682753474556b802268aafbed2349491972072 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 22 Jul 2026 21:26:55 +0000 Subject: [PATCH 08/19] Renamed the Web package as Infrastructure (#24) Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/24 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcschemes/Infrastructure.xcscheme} | 12 ++++++------ Packages/{Web => Infrastructure}/Package.swift | 12 ++++++------ .../Public/Builders/RouteCollectionBuilder.swift | 0 .../RouterMethods+RouteCollections.swift | 0 .../Public/Protocols/RouterController.swift | 0 .../RouterMethods+RouteCollectionsTests.swift | 2 +- .../Tests/Utils/Controllers/StubController.swift | 2 +- Services/Website/Dockerfile | 4 ++-- Services/Website/Package.swift | 6 +++--- Services/Website/README.md | 2 +- .../Sources/App/Extensions/App+Build.swift | 2 +- .../Public/Controllers/HealthController.swift | 2 +- .../Public/Controllers/RootController.swift | 2 +- Services/Website/Tests/App/AppTests.swift | 16 ---------------- Services/Website/Tests/Website.xctestplan | 6 +++--- 15 files changed, 26 insertions(+), 42 deletions(-) rename Packages/{Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme => Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme} (88%) rename Packages/{Web => Infrastructure}/Package.swift (79%) rename Packages/{Web => Infrastructure}/Sources/Public/Builders/RouteCollectionBuilder.swift (100%) rename Packages/{Web => Infrastructure}/Sources/Public/Extensions/RouterMethods+RouteCollections.swift (100%) rename Packages/{Web => Infrastructure}/Sources/Public/Protocols/RouterController.swift (100%) rename Packages/{Web => Infrastructure}/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift (99%) rename Packages/{Web => Infrastructure}/Tests/Utils/Controllers/StubController.swift (96%) diff --git a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme similarity index 88% rename from Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme rename to Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme index 5212084..56914ab 100644 --- a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme +++ b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme @@ -15,8 +15,8 @@ buildForAnalyzing = "YES"> @@ -33,8 +33,8 @@ skipped = "NO"> @@ -61,8 +61,8 @@ diff --git a/Packages/Web/Package.swift b/Packages/Infrastructure/Package.swift similarity index 79% rename from Packages/Web/Package.swift rename to Packages/Infrastructure/Package.swift index c0c94e8..2f9227a 100644 --- a/Packages/Web/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -3,15 +3,15 @@ import PackageDescription let package = Package( - name: "Web", + name: "Infrastructure", platforms: [ .macOS(.v15), ], products: [ .library( - name: "Web", + name: "Infrastructure", targets: [ - "Web" + "Infrastructure" ] ), ], @@ -23,7 +23,7 @@ let package = Package( ], targets: [ .target( - name: "Web", + name: "Infrastructure", dependencies: [ .product( name: "Hummingbird", @@ -33,9 +33,9 @@ let package = Package( path: "Sources" ), .testTarget( - name: "WebTests", + name: "InfrastructureTests", dependencies: [ - .byName(name: "Web"), + .byName(name: "Infrastructure"), .product( name: "HummingbirdTesting", package: "hummingbird" diff --git a/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift similarity index 100% rename from Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift rename to Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift diff --git a/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift similarity index 100% rename from Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift rename to Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift diff --git a/Packages/Web/Sources/Public/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift similarity index 100% rename from Packages/Web/Sources/Public/Protocols/RouterController.swift rename to Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift similarity index 99% rename from Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 2f6edc5..0bae410 100644 --- a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -3,7 +3,7 @@ import HummingbirdTesting import NIOCore import Testing -@testable import Web +@testable import Infrastructure @Suite("addController method") struct RouterMethodsTests { diff --git a/Packages/Web/Tests/Utils/Controllers/StubController.swift b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift similarity index 96% rename from Packages/Web/Tests/Utils/Controllers/StubController.swift rename to Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift index 75b7586..48e2e24 100644 --- a/Packages/Web/Tests/Utils/Controllers/StubController.swift +++ b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift @@ -1,5 +1,5 @@ import Hummingbird -import Web +import Infrastructure /// A controller serving its path back as plain text, used to observe route registration. struct StubController { diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 98ac38a..f0fef04 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -49,7 +49,7 @@ WORKDIR /build # 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/Web/Package.swift ./Packages/Web/ +COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve @@ -57,7 +57,7 @@ RUN swift package --package-path ./Services/Website resolve # in the assets stage and copied into staging after the binary is produced. COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources -COPY ./Packages/Web/Sources ./Packages/Web/Sources +COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 39c5aad..72dd107 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -27,7 +27,7 @@ let package = Package( path: "../../Packages/Persistence" ), .package( - path: "../../Packages/Web" + path: "../../Packages/Infrastructure" ), .package( url: "https://github.com/elementary-swift/elementary.git", @@ -80,7 +80,7 @@ let package = Package( dependencies: [ .byName(name: "Localization"), .byName(name: "Persistence"), - .byName(name: "Web"), + .byName(name: "Infrastructure"), .product( name: "Configuration", package: "swift-configuration" @@ -120,7 +120,7 @@ let package = Package( name: "WebsiteLibraryTests", dependencies: [ .byName(name: "Persistence"), - .byName(name: "Web"), + .byName(name: "Infrastructure"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/README.md b/Services/Website/README.md index ccaa62c..8178dfc 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -26,7 +26,7 @@ Two SwiftPM targets: The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `Web` (`Packages/Web`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. +- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 45814b8..e7bd7bc 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -3,7 +3,7 @@ import Hummingbird import HummingbirdCompression import Logging import Persistence -import Web +import Infrastructure import WebsiteLibrary /// Builds the website application. diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index 55f732e..4597e27 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -1,7 +1,7 @@ import Hummingbird import NIOCore import Persistence -import Web +import Infrastructure /// Serves the website's health-check routes. /// diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 7a0dff0..f4995ed 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,5 +1,5 @@ import Hummingbird -import Web +import Infrastructure /// Serves the website's root routes. /// diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 1b45edb..23fe521 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -48,22 +48,6 @@ 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( diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index 0a36976..b82c471 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -48,9 +48,9 @@ }, { "target" : { - "containerPath" : "container:..\/..\/Packages\/Web", - "identifier" : "WebTests", - "name" : "WebTests" + "containerPath" : "container:..\/..\/Packages\/Infrastructure", + "identifier" : "InfrastructureTests", + "name" : "InfrastructureTests" } } ], From cdded06ba3e7bb2c88dce2a8b2871acb1e6c60f8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 23 Jul 2026 01:04:37 +0000 Subject: [PATCH 09/19] Renamed the Web package as Infrastructure (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/25 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Packages/Infrastructure/Package.swift | 23 +- Packages/Infrastructure/README.md | 34 ++ .../Sources/Internal/Types/FNV1aHash.swift | 47 +++ .../Public/Enumerations/AssetExtension.swift | 53 ++++ .../Extensions/HTTPFieldName+Constants.swift | 4 +- .../Public/Extensions/Int+Constants.swift | 9 + .../Public/Extensions/String+Constants.swift | 23 ++ .../Public/Methods/FingerprintAssets.swift | 85 +++++ .../Middlewares/LocalizationMiddleware.swift | 10 +- .../Middlewares/NotFoundMiddleware.swift | 33 +- .../Middlewares/RateLimitMiddleware.swift | 291 ++++++++++++++++++ .../SecurityHeadersMiddleware.swift | 21 +- .../Public/Middlewares/VaryMiddleware.swift | 71 +++++ .../Sources/Public/Protocols/Asset.swift | 81 +++++ .../Protocols/LocalizedRequestContext.swift | 15 + .../Sources/Public/Protocols/Page.swift | 87 ++++++ .../Public/Responses/CachedHTMLResponse.swift | 114 +++++++ .../LocalizedHTMLCollectionResponse.swift | 31 +- .../Cases/Internal/Types/FNV1aHashTests.swift | 76 +++++ .../Enumerations/AssetExtensionTests.swift | 71 +++++ .../Methods/FingerprintAssetsTests.swift | 171 ++++++++++ .../LocalizationMiddlewareTests.swift | 69 +++++ .../Middlewares/NotFoundMiddlewareTests.swift | 191 ++++++++++++ .../RateLimitMiddlewareTests.swift | 170 ++++++++++ .../SecurityHeadersMiddlewareTests.swift | 42 ++- .../Middlewares/VaryMiddlewareTests.swift | 131 ++++++++ .../Cases/Public/Protocols/AssetTests.swift | 79 +++++ .../Cases/Public/Protocols/PageTests.swift | 62 ++++ .../Tests/Catalogs/Localizable.xcstrings | 23 ++ .../Tests/Utils/Assets/StubAsset.swift | 11 + .../Utils/Contexts/StubRequestContext.swift | 26 ++ .../Utils/Extensions/Tag+Constants.swift | 8 + .../Tests/Utils/Pages/StubPage.swift | 66 ++++ Services/Website/Dockerfile | 2 +- Services/Website/Package.swift | 1 + Services/Website/README.md | 12 +- .../Sources/App/Extensions/App+Build.swift | 27 +- .../Extensions/ConfigReader+Properties.swift | 37 ++- .../Internal/Enumerations/StaticFile.swift | 105 +------ .../Internal/Extensions/Page+Defaults.swift | 70 +++++ .../Library/Internal/Pages/ErrorPage.swift | 21 +- .../Library/Internal/Pages/IndexPage.swift | 21 +- .../Library/Internal/Protocols/Page.swift | 109 ------- .../Responses/CachedHTMLResponse.swift | 72 ----- ...text.swift => WebsiteRequestContext.swift} | 26 +- .../Public/Controllers/RootController.swift | 19 +- .../AbsoluteConfigKey+Constants.swift | 13 +- .../Extensions/ConfigKey+Constants.swift | 13 +- .../Public/Extensions/Int+Constants.swift | 4 +- .../LocalizationMiddleware+Defaults.swift | 13 + .../NotFoundMiddleware+Defaults.swift | 21 ++ .../Public/Extensions/String+Constants.swift | 21 -- Services/Website/Tests/App/AppTests.swift | 179 ++++++++++- .../Enumerations/StaticFileTests.swift | 124 +------- .../Cases/Internal/Pages/IndexPageTests.swift | 14 + .../Controllers/RootControllerTests.swift | 114 +++++++ .../LocalizationMiddlewareTests.swift | 2 + .../Middlewares/NotFoundMiddlewareTests.swift | 95 +++++- 58 files changed, 2844 insertions(+), 519 deletions(-) create mode 100644 Packages/Infrastructure/README.md create mode 100644 Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift create mode 100644 Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Extensions/HTTPFieldName+Constants.swift (72%) create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/LocalizationMiddleware.swift (87%) rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/NotFoundMiddleware.swift (62%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/SecurityHeadersMiddleware.swift (88%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Asset.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Page.swift create mode 100644 Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift rename {Services/Website/Sources/Library/Internal => Packages/Infrastructure/Sources/Public}/Responses/LocalizedHTMLCollectionResponse.swift (70%) create mode 100644 Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift rename {Services/Website/Tests/Library => Packages/Infrastructure/Tests}/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift (71%) create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift create mode 100644 Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings create mode 100644 Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift delete mode 100644 Services/Website/Sources/Library/Internal/Protocols/Page.swift delete mode 100644 Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift rename Services/Website/Sources/Library/Public/Contexts/{LocalizedRequestContext.swift => WebsiteRequestContext.swift} (57%) create mode 100644 Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift index 2f9227a..e399fba 100644 --- a/Packages/Infrastructure/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -16,6 +16,13 @@ let package = Package( ), ], 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" @@ -25,6 +32,11 @@ let package = Package( .target( name: "Infrastructure", dependencies: [ + .byName(name: "Localization"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "Hummingbird", package: "hummingbird" @@ -36,12 +48,21 @@ let package = Package( name: "InfrastructureTests", dependencies: [ .byName(name: "Infrastructure"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "HummingbirdTesting", package: "hummingbird" ), ], - path: "Tests" + 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..7913a19 --- /dev/null +++ b/Packages/Infrastructure/README.md @@ -0,0 +1,34 @@ +# 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` | +| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | +| Contexts | `LocalizedRequestContext` | + +## Design rules +The package holds only what every service can reuse; anything a service owns is injected, never referenced: + +- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. +- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. +- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +## Layout +Sources are split by visibility, then by kind, one type per file: + +``` +Sources/Public// public API (Protocols, Middlewares, Responses, …) +Sources/Internal// implementation details (e.g. FNV1aHash) +Tests/Cases/… mirrors the source layout +Tests/Utils/… stubs and test-only extensions +``` + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift new file mode 100644 index 0000000..c33baab --- /dev/null +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -0,0 +1,47 @@ +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/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift new file mode 100644 index 0000000..8729209 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -0,0 +1,53 @@ +/// 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/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..d01536e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -0,0 +1,23 @@ +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..3a55d87 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -0,0 +1,85 @@ +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 87% rename from Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index db18270..c743aa8 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -1,3 +1,4 @@ +import Foundation import HTTPTypes import Hummingbird import Localization @@ -19,9 +20,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 88% rename from Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index 4bd48e9..ffa531f 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -4,7 +4,7 @@ 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 +/// 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. /// @@ -40,6 +40,9 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// Passes the request down the chain and stamps the configured security headers onto the /// response on the way back up. /// + /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the + /// controllers) are converted to their response here rather than left to the router: the router + /// converts them above the middleware chain, where the response would escape these headers. /// Existing values for the same header names are replaced so downstream middleware cannot leave /// a weaker policy in place. /// - Parameters: @@ -47,13 +50,25 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// - 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 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..6624516 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -0,0 +1,81 @@ +/// 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..86fec3e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -0,0 +1,15 @@ +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..1ab393e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -0,0 +1,87 @@ +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 and stylesheet links followed by the metadata 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 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 after the ``stylesheets``: 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 stylesheets linked in the document head, in order. + var stylesheets: [any Asset] { get } + +} + +// MARK: - Implementations + +public extension Page { + + // MARK: Computed + + /// 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 and ``stylesheets`` links followed by the ``metadata``, placed in the document head. + /// + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already + /// emits `` before this markup, and HTML5 allows only one. + @HTMLBuilder + var head: some HTML { + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + + metadata + + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath( + for: .css, + version: assetVersion + )) + ) + } + } + +} 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 70% rename from Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift rename to Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index e6772d1..b8c1161 100644 --- a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -10,11 +10,11 @@ import Localization /// 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 { +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 +24,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 +41,9 @@ struct LocalizedHTMLCollectionResponse: Sendable { .contentLanguage: language, .vary: "Accept-Language", ], - document: document(.init(identifier: language)) + document: document(.init( + identifier: language + )) ) } } @@ -47,19 +51,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. + /// - 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. - func response( - for language: String + 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/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift new file mode 100644 index 0000000..e876252 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift @@ -0,0 +1,76 @@ +import Testing + +@testable import Infrastructure + +@Suite("FNV1aHash 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..98730b5 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -0,0 +1,71 @@ +import Testing + +@testable import Infrastructure + +@Suite("AssetExtension enumeration") +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/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift new file mode 100644 index 0000000..5c073e8 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift @@ -0,0 +1,171 @@ +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("FingerprintAssets method") +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/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift new file mode 100644 index 0000000..c18276e --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -0,0 +1,69 @@ +import Foundation +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import Infrastructure + +@Suite("LocalizationMiddleware middleware", .tags(.middleware)) +struct LocalizationMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + } + + router.get("language") { _, context in + context.language + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `negotiates a supported language from the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "de-DE,de;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + @Test + func `falls back to the default without a header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + + @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..895d90c --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -0,0 +1,191 @@ +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..5c6bd32 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -0,0 +1,170 @@ +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 71% rename from Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index e987adc..78db8c0 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -2,7 +2,7 @@ import Hummingbird import HummingbirdTesting import Testing -@testable import WebsiteLibrary +@testable import Infrastructure @Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) struct SecurityHeadersMiddlewareTests { @@ -17,11 +17,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 +91,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 +121,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 +146,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..afa7dad --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift @@ -0,0 +1,131 @@ +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..ea9c778 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import Infrastructure + +@Suite("Asset 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..a09a533 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -0,0 +1,62 @@ +import Elementary +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("Page protocol", .tags(.page)) +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 `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/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/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..e83de46 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,8 @@ +import Testing + +extension Tag { + /// Tests exercising a middleware of the Infrastructure package. + @Tag static var middleware: Tag + /// Tests exercising the page scaffolding of the Infrastructure package. + @Tag static var page: Tag +} diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift new file mode 100644 index 0000000..eae187d --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -0,0 +1,66 @@ +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 locale the page content is localized to. + let locale: Locale + + // 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. + init( + locale: Locale = .init(identifier: "en"), + assetVersion: String? = nil + ) { + self.assetVersion = assetVersion + self.locale = locale + } + + // 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/Services/Website/Dockerfile b/Services/Website/Dockerfile index f0fef04..2570487 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -55,9 +55,9 @@ RUN swift package --package-path ./Services/Website resolve # 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/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 72dd107..0163147 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -108,6 +108,7 @@ let package = Package( .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Website"), .product( name: "HummingbirdTesting", diff --git a/Services/Website/README.md b/Services/Website/README.md index 8178dfc..9eef3ae 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -26,7 +26,7 @@ Two SwiftPM targets: The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. +- `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. 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). @@ -65,7 +65,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). | @@ -106,6 +107,13 @@ 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 +| 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 | | --- | --- | --- | diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index e7bd7bc..d4f66ed 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -26,6 +26,8 @@ func application( logger: logger ) let fluent = persistence() + + let fingerprintAssets = FingerprintAssets(logger: logger) let prepareDB = PrepareDB() await prepareDB(for: fluent) @@ -33,8 +35,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) @@ -117,25 +121,31 @@ 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, the `SubscriptionController` routes that register newsletter subscriptions, 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 @@ -152,11 +162,14 @@ private func router( SecurityHeadersMiddleware( configuration: securityHeaders ) + VaryMiddleware() ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) LocalizationMiddleware() - NotFoundMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) FileMiddleware( staticFilesPath, cacheControl: cacheControl @@ -164,7 +177,9 @@ private func router( } router.addController { - RootController() + RootController( + assetVersion: assetVersion + ) HealthController( probe: probe ) diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 2f9fdc2..9acbbe5 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/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 69afb22..d953601 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,9 +1,11 @@ +import Infrastructure + /// A static file shipped with the website service. /// /// 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: CaseIterable, Sendable { +enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. @@ -28,30 +30,6 @@ enum StaticFile: CaseIterable, Sendable { case sitemap } -// 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 - /// An Extensible Markup Language file. - case xml - } -} - // MARK: - Extensions extension StaticFile { @@ -59,7 +37,7 @@ extension StaticFile { // MARK: Computed /// The file extensions the file is available with. - var fileExtensions: [Extension] { + var fileExtensions: [AssetExtension] { switch self { case .appleTouchIcon, .icon192, @@ -92,79 +70,4 @@ extension StaticFile { } } - // MARK: Methods - - /// Resolves the file'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: Extension - ) -> String { - let relativePath = relativePath(for: fileExtension) - - guard !basePath.isEmpty else { - return relativePath - } - - return "\(basePath)/\(relativePath)" - } - - /// Resolves the file'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: Extension - ) -> String { - let file = "\(fileName).\(fileExtension.rawValue)" - - return fileExtension.subdirectory - .map { "\($0)/\(file)" } ?? file - } - - /// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`). - /// - /// - Parameter fileExtension: the extension of the file to resolve. - /// - Returns: the path to use in `href` and `src` attributes. - func urlPath( - for fileExtension: Extension - ) -> String { - "/\(relativePath(for: fileExtension))" - } - -} - -extension StaticFile.Extension { - - // 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/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift new file mode 100644 index 0000000..71eefba --- /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(bundle: .module).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 index 3f332fb..2b19f7b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -1,5 +1,6 @@ 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. @@ -7,6 +8,9 @@ struct ErrorPage { // 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 @@ -16,10 +20,15 @@ struct ErrorPage { // MARK: Initializers /// Creates a not-found 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) } @@ -40,12 +49,12 @@ extension ErrorPage: Page { } } - var scripts: [StaticFile] { - [.error, .shared] + var scripts: [any Asset] { + [StaticFile.error, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .error] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.error] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index aabef0b..bcb0475 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -1,5 +1,6 @@ import Elementary import Foundation +import Infrastructure import Localization /// The website's landing page, with its text localized to a given locale. @@ -7,6 +8,9 @@ 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. let locale: Locale @@ -16,10 +20,15 @@ struct IndexPage { // 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) } @@ -38,12 +47,12 @@ extension IndexPage: Page { } } - var scripts: [StaticFile] { - [.index, .shared] + var scripts: [any Asset] { + [StaticFile.index, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .index] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.index] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift deleted file mode 100644 index e8bc515..0000000 --- a/Services/Website/Sources/Library/Internal/Protocols/Page.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Elementary -import Foundation -import Localization - -/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content. -/// -/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the -/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the -/// document language derived from the locale. -protocol Page: HTMLDocument, Sendable { - - // MARK: Associated types - - /// The type of the page's markup. - associatedtype Content: HTML - - // MARK: Properties - - /// 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 scripts loaded at the end of the document body, in order. - var scripts: [StaticFile] { get } - - /// The stylesheets linked in the document head, in order. - var stylesheets: [StaticFile] { get } - -} - -// MARK: - Implementations - -extension Page { - - // MARK: Computed - - /// The page ``content`` followed by its ``scripts``. - @HTMLBuilder - var body: some HTML { - content - for file in scripts { - script(.src(file.urlPath(for: .js))) {} - } - } - - /// The metadata, ``stylesheets``, icon, and manifest 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. - @HTMLBuilder - var head: some HTML { - meta( - .name(.viewport), - .content("width=device-width, initial-scale=1") - ) - for file in stylesheets { - link( - .rel(.stylesheet), - .href(file.urlPath(for: .css)) - ) - } - link( - .rel(.icon), - .href(StaticFile.favicon.urlPath(for: .ico)), - .custom( - name: "sizes", - value: "any" - ) - ) - link( - .rel(.icon), - .href(StaticFile.icon.urlPath(for: .svg)), - .custom( - name: "type", - value: "image/svg+xml" - ) - ) - link( - .rel("apple-touch-icon"), - .href(StaticFile.appleTouchIcon.urlPath(for: .png)) - ) - link( - .rel("manifest"), - .href(StaticFile.site.urlPath(for: .webmanifest)) - ) - meta( - .name("theme-color"), - .content("#fafafa") - ) - meta( - .name("theme-color"), - .content("#0c0710"), - .custom( - name: "media", - value: "(prefers-color-scheme: dark)" - ) - ) - } - - /// 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 - } - -} 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 57% rename from Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift rename to Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 0172f98..36e98d2 100644 --- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -1,26 +1,13 @@ 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 { +/// 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 +15,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 +27,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/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index f4995ed..fef2cb9 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,3 +1,4 @@ +import Foundation import Hummingbird import Infrastructure @@ -24,9 +25,16 @@ public struct RootController { // 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 + ) } } @@ -70,7 +78,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/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..545dd1f --- /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) { + ErrorPage( + locale: $0, + assetVersion: assetVersion + ) + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 23fa620..a70db40 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 23fe521..70fce3c 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -2,6 +2,7 @@ import Configuration import Foundation import Hummingbird import HummingbirdTesting +import Infrastructure import NIOCore import Testing @@ -12,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 @@ -48,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( @@ -106,7 +123,9 @@ struct AppTests { #expect(cacheControl.contains("public") == true) #expect(cacheControl.contains("max-age=") == true) - if textExtensions.contains(fileExtension) { + if immutableExtensions.contains(fileExtension) { + #expect(cacheControl.contains("immutable") == true) + } else if fileExtension == .txt { #expect(cacheControl.contains("must-revalidate") == true) } } @@ -114,6 +133,81 @@ struct AppTests { } } + @Test + func `versioned asset URL to be served`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/css/shared.css?v=0123456789abcdef", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "text/css") + } + } + } + + @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")) + } + } + } + @Test func `response to be compressed when the client supports it`() async throws { try await app( @@ -163,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/error.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 2ca9b20..449238c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -1,3 +1,4 @@ +import Infrastructure import Testing @testable import WebsiteLibrary @@ -8,7 +9,6 @@ struct StaticFileTests { // MARK: Type aliases typealias File = StaticFile - typealias FileExtension = StaticFile.Extension // MARK: Computed tests @@ -18,7 +18,7 @@ struct StaticFileTests { )) func `file extensions`( for file: File, - expects extensions: [FileExtension] + expects extensions: [AssetExtension] ) { #expect(file.fileExtensions == extensions) } @@ -34,81 +34,6 @@ struct StaticFileTests { #expect(file.fileName == fileName) } - @Test(arguments: zip( - Self.extensions, - Self.contentTypes - )) - func `content type`( - for fileExtension: FileExtension, - expects contentType: String - ) { - #expect(fileExtension.contentType == contentType) - } - - @Test(arguments: zip( - Self.extensions, - Self.subdirectories - )) - func `subdirectory`( - for fileExtension: FileExtension, - expects subdirectory: String? - ) { - #expect(fileExtension.subdirectory == subdirectory) - } - - // MARK: Method tests - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `relative path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.relativePath(for: fileExtension) == relativePath) - } - } - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `url path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.urlPath(for: fileExtension) == "/\(relativePath)") - } - } - - @Test(arguments: [ - "", - ".", - "Resources/Static" - ]) - func `path relative to`( - _ basePath: String - ) { - for file in File.allCases { - for fileExtension in file.fileExtensions { - let pathRelativeToBasePath = file.path( - relativeTo: basePath, - for: fileExtension - ) - let relativePath = file.relativePath(for: fileExtension) - - if basePath.isEmpty { - #expect(pathRelativeToBasePath == relativePath) - } else { - #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") - } - } - } - } - // MARK: CaseIterable tests @Test @@ -124,37 +49,7 @@ private extension StaticFileTests { // MARK: Constants - static let extensions: [FileExtension] = [ - .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 - ] - static let fileExtensions: [[FileExtension]] = [ + static let fileExtensions: [[AssetExtension]] = [ [.png], [.css, .js], [.ico], @@ -180,18 +75,5 @@ private extension StaticFileTests { "site", "sitemap" ] - static let relativePaths: [[String]] = [ - ["apple-touch-icon.png"], - ["css/error.css", "js/error.js"], - ["favicon.ico"], - ["icon.svg"], - ["icon-192.png"], - ["icon-512.png"], - ["css/index.css", "js/index.js"], - ["robots.txt"], - ["css/shared.css", "js/shared.js"], - ["site.webmanifest"], - ["sitemap.xml"] - ] } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index fc308d2..a98bb33 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -29,4 +29,18 @@ struct IndexPageTests { #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/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index 54c3a4f..b705b7a 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -1,5 +1,6 @@ import Hummingbird import HummingbirdTesting +import Infrastructure import NIOCore import Testing @@ -42,4 +43,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/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index 9b71671..60c8871 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -4,6 +4,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("LocalizationMiddleware middleware", .tags(.middleware)) diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 3cffe32..22f1f9b 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -3,6 +3,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("NotFoundMiddleware middleware", .tags(.middleware)) @@ -70,11 +72,102 @@ struct NotFoundMiddlewareTests { method: .get ) { response in let body = String(buffer: response.body) - + #expect(response.status == .badRequest) #expect(!body.contains("Page Not Found")) } } } + @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/error.css?v=0123456789abcdef")) + #expect(body.contains("/js/shared.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/error.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("Page Not Found")) + } + } + } + +} + +// 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: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) + } + + return Application(router: router) + } + } From 088713532836fe053cb44431ce86af5036ce133c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 28 Jul 2026 23:37:33 +0000 Subject: [PATCH 10/19] Fixes for the Localization package (#26) This PR contains the work done to Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/26 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Internal/Protocols/CatalogResolving.swift | 6 +- .../Internal/Types/LanguageRange.swift | 18 +++ .../Internal/Types/StringCatalog.swift | 120 ++++++++++++++-- .../Public/Enumerations/CatalogState.swift | 17 +++ .../Sources/Public/Methods/Localize.swift | 12 +- .../Sources/Public/Methods/Negotiate.swift | 131 +++++++++++++----- .../Sources/Public/Types/LanguageList.swift | 41 +++--- .../Cases/Public/Methods/LocalizeTests.swift | 86 ++++++++++++ .../Cases/Public/Methods/NegotiateTests.swift | 53 ++++++- .../Public/Types/LanguageListTests.swift | 55 ++++++-- .../Tests/Catalogs/Localizable.xcstrings | 6 + .../Tests/Utils/Resolvers/StubCatalog.swift | 23 +++ Services/Website/Package.swift | 1 + .../Sources/App/Extensions/App+Build.swift | 14 +- .../Internal/Extensions/Page+Defaults.swift | 2 +- .../Extensions/LanguageList+Defaults.swift | 13 ++ 16 files changed, 518 insertions(+), 80 deletions(-) create mode 100644 Packages/Localization/Sources/Internal/Types/LanguageRange.swift create mode 100644 Packages/Localization/Sources/Public/Enumerations/CatalogState.swift create mode 100644 Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift diff --git a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift index bb003fd..a0e4dcf 100644 --- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -16,9 +16,13 @@ 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..1afaa6b --- /dev/null +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -0,0 +1,18 @@ +/// 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..5eb7bd0 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,9 @@ 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 +143,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 +216,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..2e35e1b --- /dev/null +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -0,0 +1,17 @@ +/// 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..b088d27 100644 --- a/Packages/Localization/Sources/Public/Methods/Localize.swift +++ b/Packages/Localization/Sources/Public/Methods/Localize.swift @@ -12,6 +12,16 @@ 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 +32,7 @@ public struct Localize: Sendable { bundle: Bundle, table: String = "Localizable" ) { - self.init(resolver: StringCatalog( + self.init(resolver: StringCatalog.cached( bundle: bundle, table: table )) diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index 878b649..e0604be 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -4,7 +4,8 @@ import Foundation /// /// 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. +/// 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 +28,12 @@ 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 +43,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,27 +72,69 @@ 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. @@ -97,7 +146,7 @@ private extension Negotiate { /// - 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 +166,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 +201,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..42a0e84 100644 --- a/Packages/Localization/Sources/Public/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -8,46 +8,47 @@ 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..f071950 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift @@ -32,6 +32,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 +72,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..5bd2680 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -26,6 +26,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 +48,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..ea4eaa7 100644 --- a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift @@ -20,16 +20,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 +68,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/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/Services/Website/Package.swift b/Services/Website/Package.swift index 0163147..71e1f67 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -58,6 +58,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "Localization"), .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index d4f66ed..254319f 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -1,6 +1,7 @@ import Configuration import Hummingbird import HummingbirdCompression +import Localization import Logging import Persistence import Infrastructure @@ -9,7 +10,8 @@ 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. @@ -17,10 +19,20 @@ 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 diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift index 71eefba..f852a71 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -11,7 +11,7 @@ extension Page { /// 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 + ?? LanguageList().default } /// The icon, manifest, and theme colour metadata shared by every page of the website. 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) + } + +} From ea00b94841b3dcd04e64c38eaf51ec7d3e2454ec Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 30 Jul 2026 06:33:57 +0000 Subject: [PATCH 11/19] Tweaks and fixes throughout the project (#27) This PR contains the work done to do a little bit of housekeeping pass across all packages and the Website service. To provide further details about the work: * Refreshed the READMEs and source documentation to match the current code; * Tagged every test case consistently across the Infrastructure, Localization, Persistence, and Website test targets; * Removed Website middleware tests now covered by Infrastructure's own suite; * Conformed the `PrepareDB` method to Sendable; * Relaxes the production Compose DATABASE_TLS default from require to prefer; * Added Persistence test verifying the prefer posture falls back to plaintext connections. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/27 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 28 ++- .../Sources/Internal/Types/FNV1aHash.swift | 5 +- .../Builders/RouteCollectionBuilder.swift | 4 +- .../Public/Enumerations/AssetExtension.swift | 3 +- .../RouterMethods+RouteCollections.swift | 6 +- .../Public/Extensions/String+Constants.swift | 11 +- .../Public/Methods/FingerprintAssets.swift | 12 +- .../Middlewares/LocalizationMiddleware.swift | 9 +- .../SecurityHeadersMiddleware.swift | 39 ++-- .../Sources/Public/Protocols/Asset.swift | 13 +- .../Protocols/LocalizedRequestContext.swift | 5 +- .../Sources/Public/Protocols/Page.swift | 4 +- .../Public/Protocols/RouterController.swift | 4 +- .../LocalizedHTMLCollectionResponse.swift | 11 +- .../Cases/Internal/Types/FNV1aHashTests.swift | 5 +- .../Enumerations/AssetExtensionTests.swift | 5 +- .../RouterMethods+RouteCollectionsTests.swift | 5 +- .../Methods/FingerprintAssetsTests.swift | 5 +- .../LocalizationMiddlewareTests.swift | 5 +- .../Middlewares/NotFoundMiddlewareTests.swift | 5 +- .../RateLimitMiddlewareTests.swift | 5 +- .../SecurityHeadersMiddlewareTests.swift | 5 +- .../Middlewares/VaryMiddlewareTests.swift | 5 +- .../Cases/Public/Protocols/AssetTests.swift | 5 +- .../Cases/Public/Protocols/PageTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 10 +- Packages/Localization/README.md | 44 +++++ .../Internal/Protocols/CatalogResolving.swift | 17 +- .../Internal/Types/LanguageRange.swift | 8 +- .../Internal/Types/StringCatalog.swift | 13 +- .../Public/Enumerations/CatalogState.swift | 9 +- .../Sources/Public/Methods/Localize.swift | 12 +- .../Sources/Public/Methods/Negotiate.swift | 29 ++- .../Sources/Public/Types/LanguageList.swift | 6 +- .../Cases/Public/Methods/LocalizeTests.swift | 5 +- .../Cases/Public/Methods/NegotiateTests.swift | 5 +- .../Public/Types/LanguageListTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 8 + Packages/Persistence/Package.swift | 22 ++- Packages/Persistence/README.md | 60 ++++++ .../Sources/Public/Enumerations/Driver.swift | 11 +- .../Sources/Public/Enumerations/TLS.swift | 23 ++- .../Sources/Public/Methods/PrepareDB.swift | 12 +- .../Sources/Public/Methods/Probe.swift | 13 +- .../Sources/Public/Methods/Service.swift | 14 +- .../Sources/Public/Types/Configuration.swift | 3 +- .../Cases/Public/Enumerations/TLSTests.swift | 32 +++- .../Cases/Public/Methods/ProbeTests.swift | 5 +- .../Cases/Public/Methods/ServiceTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 8 + .../Utils/Fakes/PlaintextMySQLServer.swift | 162 ++++++++++++++++ Services/Website/Dockerfile | 25 ++- Services/Website/README.md | 22 ++- Services/Website/Sources/App/App.swift | 6 +- .../Sources/App/Extensions/App+Build.swift | 15 +- .../Extensions/ConfigReader+Properties.swift | 6 +- .../Internal/Enumerations/StaticFile.swift | 5 +- .../Library/Internal/Pages/IndexPage.swift | 3 +- .../Contexts/WebsiteRequestContext.swift | 5 +- .../Public/Controllers/HealthController.swift | 13 +- .../Public/Controllers/RootController.swift | 12 +- .../Enumerations/StaticFileTests.swift | 5 +- .../Cases/Internal/Pages/ErrorPageTests.swift | 5 +- .../Cases/Internal/Pages/IndexPageTests.swift | 5 +- .../Controllers/HealthControllerTests.swift | 5 +- .../Controllers/RootControllerTests.swift | 5 +- .../LocalizationMiddlewareTests.swift | 57 ------ .../Middlewares/NotFoundMiddlewareTests.swift | 173 ------------------ .../Utils/Extensions/Tag+Constants.swift | 4 +- Services/Website/docker-compose.override.yml | 12 +- Services/Website/docker-compose.yml | 12 +- 71 files changed, 633 insertions(+), 512 deletions(-) create mode 100644 Packages/Localization/README.md create mode 100644 Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Persistence/README.md create mode 100644 Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift delete mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift delete mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 7913a19..cade1d7 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -3,7 +3,6 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too ## Overview The package provides, grouped by role: - | Role | Types | | --- | --- | | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | @@ -11,23 +10,36 @@ The package provides, grouped by role: | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | +| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules The package holds only what every service can reuse; anything a service owns is injected, never referenced: - - **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. ## Layout Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +├── Public/ public API +│ ├── Builders/ RouteCollectionBuilder +│ ├── Enumerations/ AssetExtension +│ ├── Extensions/ addController, plus the default header names and values +│ ├── Methods/ FingerprintAssets +│ ├── Middlewares/ the five HTTP middlewares +│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController +│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +└── Internal/ + └── Types/ implementation details (FNV1aHash) +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux +└── Utils/ stubs (StubAsset, StubPage, …) and the suite Tag constants +``` -``` -Sources/Public// public API (Protocols, Middlewares, Responses, …) -Sources/Internal// implementation details (e.g. FNV1aHash) -Tests/Cases/… mirrors the source layout -Tests/Utils/… stubs and test-only extensions -``` +## Testing +Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift index c33baab..4165586 100644 --- a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -2,9 +2,8 @@ import Foundation /// Hashes bytes with the FNV-1a 64-bit algorithm. /// -/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it -/// suits values that must agree between instances and survive restarts: the asset version token -/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). +/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive +/// restarts: the asset version token (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). /// It is not cryptographic — a collision only risks serving a stale cached asset, not security. struct FNV1aHash { diff --git a/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift index 548bc46..a201c2e 100644 --- a/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift +++ b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift @@ -2,8 +2,8 @@ import Hummingbird /// A result builder that collects the route collections of ``RouterController`` values into a stack. /// -/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting -/// controllers be listed declaratively rather than having their routes added one statement at a time. +/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting controllers be listed declaratively rather than having +/// their routes added one statement at a time. @resultBuilder public enum RouteCollectionBuilder { diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift index 8729209..9d1ae00 100644 --- a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -1,7 +1,6 @@ /// A file extension used by an ``Asset``. /// -/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its -/// file name when resolving paths. +/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths. public enum AssetExtension: String, Sendable { /// A Cascading Style Sheets file. case css diff --git a/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift index d753351..25c55f2 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift @@ -4,8 +4,7 @@ public extension RouterMethods { // MARK: Methods - /// Adds the routes of ``RouterController`` values to the router using the - /// ``RouteCollectionBuilder`` result builder. + /// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder. /// /// Mirrors `addMiddleware`, letting controllers be listed declaratively: /// @@ -16,8 +15,7 @@ public extension RouterMethods { /// } /// ``` /// - /// Each controller's route collection is added at the router's root, exactly as a - /// sequence of `addRoutes(_:)` calls would. + /// Each controller's route collection is added at the router's root, exactly as a sequence of `addRoutes(_:)` calls would. /// - Parameter build: the controller stack result builder. /// - Returns: the router, so calls can be chained. @discardableResult diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift index d01536e..2791b1e 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -1,15 +1,14 @@ extension String { /// A namespace for the security headers' default configuration values. /// - /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is - /// "sticky" in browsers, so it stays off unless explicitly configured in production. + /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is "sticky" in browsers, so it stays off unless explicitly + /// configured in production. public enum Security { /// The default `Content-Security-Policy`. /// - /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins - /// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing - /// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link - /// external stylesheets. + /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document + /// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must + /// link external stylesheets. public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" /// The default `X-Content-Type-Options` (disables MIME sniffing). public static let contentTypeOptions = "nosniff" diff --git a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift index 3a55d87..70a2097 100644 --- a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -28,9 +28,8 @@ public struct FingerprintAssets: Sendable { /// Fingerprints the static files under the given directory. /// - /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its - /// later changes would not bust caches — a warning there usually points at a permissions - /// problem in the deployment. + /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its later changes would not bust caches — a warning there + /// usually points at a permissions problem in the deployment. /// - Parameter path: the directory the static files are served from. /// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned). public func callAsFunction( @@ -42,10 +41,9 @@ public struct FingerprintAssets: Sendable { return nil } - // The path-based enumerator yields paths relative to the directory, so the token depends - // only on the directory's contents — never on where the directory itself lives (the - // URL-based enumerator standardizes symlinked bases, e.g. `/var/…` to `/private/var/…`, - // which would leak the absolute path into the hash). + // The path-based enumerator yields paths relative to the directory, so the token depends only on the + // directory's contents — never on where the directory itself lives (the URL-based enumerator standardizes + // symlinked bases, e.g. `/var/…` to `/private/var/…`, which would leak the absolute path into the hash). var files: [String] = [] while let relativePath = enumerated.nextObject() as? String { diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index c743aa8..964909c 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -5,12 +5,11 @@ import Localization /// Resolves the visitor's preferred language and records it on the request context. /// -/// Placed ahead of the localized responders in the middleware chain, it reads the request's -/// `Accept-Language` header, negotiates the best supported match (falling back to the default -/// language), and stores it on the context's ``LocalizedRequestContext/language``. +/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported +/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``. /// -/// The request is otherwise passed through untouched — the URL and routing are not affected — so -/// each page is served at its existing path and varies its content by header. +/// The request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its +/// content by header. public struct LocalizationMiddleware { // MARK: Properties diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index ffa531f..8658e8c 100644 --- a/Packages/Infrastructure/Sources/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 pages, the error page produced by -/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies -/// the strict, hardened interpretation of the content instead of its lenient legacy defaults. +/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up — the rendered pages, the +/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies the strict, hardened +/// interpretation of the content instead of its lenient legacy defaults. /// -/// The headers are precomputed once from the ``Configuration`` at initialization and reused for -/// every request, so the per-request cost is a handful of header copies. +/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of +/// header copies. public struct SecurityHeadersMiddleware { // 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,14 +35,11 @@ extension SecurityHeadersMiddleware: RouterMiddleware { // MARK: Functions - /// Passes the request down the chain and stamps the configured security headers onto the - /// response on the way back up. + /// Passes the request down the chain and stamps the configured security headers onto the response on the way back up. /// - /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the - /// controllers) are converted to their response here rather than left to the router: the router - /// converts them above the middleware chain, where the response would escape these headers. - /// Existing values for the same header names are replaced so downstream middleware cannot leave - /// a weaker policy in place. + /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response + /// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers. + /// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. @@ -106,10 +101,9 @@ private extension SecurityHeadersMiddleware.Configuration { extension SecurityHeadersMiddleware { /// The set of security headers a ``SecurityHeadersMiddleware`` applies. /// - /// Each property maps to a single response header. A `nil` value omits that header entirely, - /// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send - /// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be - /// switched on (via configuration) only in TLS-terminated production. + /// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays + /// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on + /// (via configuration) only in TLS-terminated production. public struct Configuration: Sendable { // MARK: Properties @@ -131,9 +125,8 @@ extension SecurityHeadersMiddleware { /// Creates a security-headers configuration. /// - /// Every parameter defaults to the hardened baseline defined in `String.Security`, except - /// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header - /// to drop it from the response. + /// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults + /// to `nil` (omitted). Pass `nil` for any header to drop it from the response. /// - Parameters: /// - contentSecurityPolicy: the `Content-Security-Policy` value. /// - contentTypeOptions: the `X-Content-Type-Options` value. diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift index 6624516..be5506d 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -1,9 +1,7 @@ -/// An asset shipped with a website: a file stored under the static files root and served by -/// Hummingbird's `FileMiddleware` middleware. +/// An asset shipped with a website: a file stored under the static files root and served by Hummingbird's `FileMiddleware` middleware. /// -/// A conforming asset supplies its file name and the extensions it is available with, each -/// resolving to its own file; the protocol derives the paths from them: the file's path within -/// the static files root and the URL path it is served at, optionally versioned to bust caches. +/// A conforming asset supplies its file name and the extensions it is available with, each resolving to its own file; the protocol derives the paths from them: +/// the file's path within the static files root and the URL path it is served at, optionally versioned to bust caches. public protocol Asset: Sendable { // MARK: Properties @@ -58,9 +56,8 @@ public extension Asset { /// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`). /// - /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): - /// `FileMiddleware` ignores the query when resolving the file, while caches key on the full - /// URL, so a deploy that changes the assets busts every cached copy at once. + /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): `FileMiddleware` ignores the query when + /// resolving the file, while caches key on the full URL, so a deploy that changes the assets busts every cached copy at once. /// - Parameters: /// - fileExtension: the extension of the file to resolve. /// - version: the version token to append, or `nil` to leave the URL unversioned. diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift index 86fec3e..50dcb35 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -2,9 +2,8 @@ import Hummingbird /// A request context that carries the language negotiated for the request. /// -/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` -/// header and stores it here, so downstream controllers and middleware can serve the matching -/// localization without re-reading the header. +/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` header and stores it here, so downstream +/// controllers and middleware can serve the matching localization without re-reading the header. public protocol LocalizedRequestContext: RequestContext { // MARK: Properties diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 1ab393e..7875f6b 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -62,8 +62,8 @@ public extension Page { /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. /// - /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already - /// emits `` before this markup, and HTML5 allows only one. + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, + /// and HTML5 allows only one. @HTMLBuilder var head: some HTML { meta( diff --git a/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift index 819ec82..ae1ca19 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift @@ -2,8 +2,8 @@ import Hummingbird /// A type exposing its endpoints as a route collection ready to be added to a router. /// -/// Conforming controllers group related endpoints behind a single ``routes`` property, -/// so the application composes them declaratively with ``Hummingbird/RouterMethods/addController(_:)``: +/// Conforming controllers group related endpoints behind a single ``routes`` property, so the application composes them declaratively with +/// ``Hummingbird/RouterMethods/addController(_:)``: /// /// ```swift /// struct HealthController: RouterController { diff --git a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index b8c1161..b497105 100644 --- a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -6,10 +6,9 @@ import Localization /// A per-language collection of pre-rendered HTML responses. /// -/// At initialization it renders the document once for each language the bundle's ``LanguageList`` -/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by -/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so -/// shared caches key on the negotiated language instead of serving one language to everyone. +/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring +/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and +/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone. public struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Properties @@ -54,8 +53,8 @@ public struct LocalizedHTMLCollectionResponse: Sendable { /// - Parameters: /// - language: the negotiated language identifier. /// - request: the request the response answers, consulted for conditional revalidation. - /// - Returns: the cached response for the language, the default language's response when the - /// language is unavailable, or a `500 Internal Server Error` if neither is cached. + /// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a + /// `500 Internal Server Error` if neither is cached. public func response( for language: String, request: Request diff --git a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift index e876252..8931989 100644 --- a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("FNV1aHash type") +@Suite( + "FNV1aHash type", + .tags(.type) +) struct FNV1aHashTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift index 98730b5..f49dbea 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("AssetExtension enumeration") +@Suite( + "AssetExtension enumeration", + .tags(.asset) +) struct AssetExtensionTests { // MARK: Computed tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 0bae410..8deb485 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Infrastructure -@Suite("addController method") +@Suite( + "addController method", + .tags(.`extension`) +) struct RouterMethodsTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift index 5c073e8..cb8d213 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift @@ -3,7 +3,10 @@ import Testing @testable import Infrastructure -@Suite("FingerprintAssets method") +@Suite( + "FingerprintAssets method", + .tags(.asset) +) struct FingerprintAssetsTests { // MARK: Properties diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index c18276e..f246f5c 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -7,7 +7,10 @@ import Testing @testable import Infrastructure -@Suite("LocalizationMiddleware middleware", .tags(.middleware)) +@Suite( + "LocalizationMiddleware middleware", + .tags(.middleware) +) struct LocalizationMiddlewareTests { // MARK: Constants diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 895d90c..ea26298 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -6,7 +6,10 @@ import Testing @testable import Infrastructure -@Suite("NotFoundMiddleware middleware", .tags(.middleware)) +@Suite( + "NotFoundMiddleware middleware", + .tags(.middleware) +) struct NotFoundMiddlewareTests { // MARK: Constants diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift index 5c6bd32..75aa6c7 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("RateLimitMiddleware middleware", .tags(.middleware)) +@Suite( + "RateLimitMiddleware middleware", + .tags(.middleware) +) struct RateLimitMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index 78db8c0..c15e01d 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) +@Suite( + "SecurityHeadersMiddleware middleware", + .tags(.middleware) +) struct SecurityHeadersMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift index afa7dad..30389de 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Infrastructure -@Suite("VaryMiddleware middleware", .tags(.middleware)) +@Suite( + "VaryMiddleware middleware", + .tags(.middleware) +) struct VaryMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift index ea9c778..08b392b 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("Asset protocol") +@Suite( + "Asset protocol", + .tags(.`protocol`) +) struct AssetTests { // MARK: Properties diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index a09a533..485d65e 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("Page protocol", .tags(.page)) +@Suite( + "Page protocol", + .tags(.`protocol`) +) struct PageTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift index e83de46..9fec14b 100644 --- a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -1,8 +1,14 @@ import Testing extension Tag { + /// Tests exercising the asset scaffolding of the Infrastructure package. + @Tag static var asset: Tag + /// Tests exercising an extension of the Infrastructure package. + @Tag static var `extension`: Tag /// Tests exercising a middleware of the Infrastructure package. @Tag static var middleware: Tag - /// Tests exercising the page scaffolding of the Infrastructure package. - @Tag static var page: Tag + /// Tests exercising a protocol scaffolding of the Infrastructure package. + @Tag static var `protocol`: Tag + /// Tests exercising an internal type of the Infrastructure package. + @Tag static var type: Tag } 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 a0e4dcf..9a30292 100644 --- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -2,22 +2,19 @@ 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 - /// and as the default language a ``LanguageList`` serves. + /// 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. diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift index 1afaa6b..b4c203d 100644 --- a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -1,8 +1,7 @@ /// 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 +/// 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 { @@ -11,8 +10,7 @@ struct LanguageRange { /// 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). + /// 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 5eb7bd0..de68dbc 100644 --- a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift +++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift @@ -95,8 +95,8 @@ extension 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. + /// 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. @@ -132,9 +132,8 @@ extension StringCatalog { 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. + /// 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 @@ -162,8 +161,8 @@ 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. + /// 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 diff --git a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift index 2e35e1b..56d3ceb 100644 --- a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -1,17 +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. +/// 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 b088d27..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 @@ -16,8 +15,7 @@ public struct Localize: Sendable { /// 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``. + /// 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 } @@ -56,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 e0604be..ee41bed 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -2,10 +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, honouring the header's `q` weights as RFC 9110 prescribes and -/// 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 @@ -28,12 +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 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. + /// 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( @@ -74,9 +71,8 @@ private extension Negotiate { /// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference. /// - /// 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. + /// 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 language ranges, most preferred first. func ranges( @@ -98,8 +94,7 @@ private extension Negotiate { /// 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. + /// 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( @@ -139,8 +134,8 @@ private extension Negotiate { /// 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. diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift index 42a0e84..d3b666c 100644 --- a/Packages/Localization/Sources/Public/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -35,16 +35,14 @@ public struct LanguageList: Sendable { /// 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``. + /// 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. + /// 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 } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift index f071950..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 diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index 5bd2680..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 diff --git a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift index ea4eaa7..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 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/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 4458f0e..6f44afa 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -5,7 +5,10 @@ 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 0ef5fb1..b52dbf9 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/Services/Website/Dockerfile b/Services/Website/Dockerfile index 2570487..9693882 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -8,14 +8,13 @@ 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. +# 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. +# 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 \ @@ -23,8 +22,8 @@ RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \ && 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. +# 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 / @@ -44,17 +43,16 @@ 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 ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve -# 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 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 @@ -79,8 +77,7 @@ 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 {} ./ \; -# Create the static files directory (served by FileMiddleware) and fill it with -# the minified copies from the assets stage +# 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 diff --git a/Services/Website/README.md b/Services/Website/README.md index 9eef3ae..ec2eaaa 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -34,13 +34,14 @@ The persistence backend runs as a `Fluent` service inside the application's Serv 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 404 page on .notFound) + → FileMiddleware (serves Resources/Static) +RootController (GET / → landing page) +HealthController (GET /health → liveness, GET /health/ready → readiness) ``` ## Configuration @@ -108,6 +109,7 @@ 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. | @@ -182,7 +184,7 @@ 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 the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `WebTests`, `PersistenceTests`, and `LocalizationTests`. +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`, and `LocalizationTests`. 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 @@ -259,8 +261,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 `LoudWebsite`). | | `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/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index 679beff..d75a38f 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -31,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 254319f..2164f29 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -25,8 +25,8 @@ func application( 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. + // 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 @@ -63,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() @@ -137,8 +137,7 @@ private func logger( /// 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, the `SubscriptionController` routes that register newsletter subscriptions, and the `HealthController` routes -/// that serve the health check. +/// 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. @@ -162,8 +161,8 @@ private func router( logLevel: Logger.Level, probe: Probe ) -> Router { - // 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. + // 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 diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 9acbbe5..c596d3f 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -123,9 +123,9 @@ 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. + /// `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( diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index d953601..3196f8f 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -2,9 +2,8 @@ import Infrastructure /// A static file shipped with the website service. /// -/// 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. +/// 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 diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index bcb0475..843d85a 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -22,8 +22,7 @@ struct IndexPage { /// Creates a landing 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. + /// - 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 diff --git a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 36e98d2..5c8f037 100644 --- a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -4,9 +4,8 @@ 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, and with -/// the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. +/// 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 diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index 4597e27..b6043a0 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -28,8 +28,7 @@ public struct HealthController { // 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 ) { @@ -72,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. @@ -93,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 fef2cb9..b1a440f 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -4,8 +4,7 @@ import Infrastructure /// Serves the website's root routes. /// -/// The controller exposes its routes through its `RouterController` conformance, so the -/// application that composes it registers them declaratively: +/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively: /// /// ```swift /// router.addController { @@ -13,8 +12,7 @@ import Infrastructure /// } /// ``` /// -/// - Note: `Context` is the request context the routes are resolved against, and must match the -/// context of the router the routes are added to. +/// - 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 @@ -25,8 +23,7 @@ public struct RootController { // MARK: Initializers /// Creates a root controller. - /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` - /// (the default) to leave them unversioned. + /// - 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 ) { @@ -67,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. diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 449238c..0c141e5 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -3,7 +3,10 @@ import Testing @testable import WebsiteLibrary -@Suite("StaticFile enumeration") +@Suite( + "StaticFile enumeration", + .tags(.enumeration) +) struct StaticFileTests { // MARK: Type aliases diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index 3268b64..4a2771c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -4,7 +4,10 @@ import Testing @testable import WebsiteLibrary -@Suite("ErrorPage page", .tags(.page)) +@Suite( + "ErrorPage page", + .tags(.page) +) struct ErrorPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index a98bb33..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", .tags(.page)) +@Suite( + "IndexPage page", + .tags(.page) +) struct IndexPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift index 9d565bf..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", .tags(.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 b705b7a..a211e9b 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -6,7 +6,10 @@ import Testing @testable import WebsiteLibrary -@Suite("RootController controller", .tags(.controller)) +@Suite( + "RootController controller", + .tags(.controller) +) struct RootControllerTests { // MARK: Constants diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift deleted file mode 100644 index 60c8871..0000000 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import HTTPTypes -import Hummingbird -import HummingbirdTesting -import NIOCore -import Testing - -import Infrastructure - -@testable import WebsiteLibrary - -@Suite("LocalizationMiddleware middleware", .tags(.middleware)) -struct LocalizationMiddlewareTests { - - // MARK: Constants - - private let app: Application = .init(router: { - let router = Router(context: WebsiteRequestContext.self) - - router.addMiddleware { - LocalizationMiddleware() - } - - router.get("language") { _, context in - context.language - } - - return router - }()) - - // MARK: Functional tests - - @Test - func `negotiates a supported language from the header`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/language", - method: .get, - headers: [.acceptLanguage: "en-US,en;q=0.9"] - ) { response in - #expect(String(buffer: response.body) == "en") - } - } - } - - @Test - func `falls back to the default without a header`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/language", - method: .get - ) { response in - #expect(String(buffer: response.body) == "en") - } - } - } - -} 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 22f1f9b..0000000 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Hummingbird -import HummingbirdTesting -import NIOCore -import Testing - -import Infrastructure - -@testable import WebsiteLibrary - -@Suite("NotFoundMiddleware middleware", .tags(.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")) - } - } - } - - @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/error.css?v=0123456789abcdef")) - #expect(body.contains("/js/shared.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/error.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("Page Not Found")) - } - } - } - -} - -// 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: WebsiteRequestContext.self) - - router.addMiddleware { - LocalizationMiddleware() - NotFoundMiddleware( - assetVersion: assetVersion - ) - } - - return Application(router: router) - } - -} diff --git a/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift index 2dee5a2..63cb88f 100644 --- a/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift +++ b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift @@ -3,8 +3,8 @@ import Testing extension Tag { /// Tests exercising a controller of the Website library. @Tag static var controller: Tag - /// Tests exercising a middleware of the Website library. - @Tag static var middleware: 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/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index c297e23..4ca579d 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 0aa0ffb..06c973c 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -6,9 +6,8 @@ name: loud-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,16 +20,15 @@ services: LOG_LEVEL: ${LOG_LEVEL:-info} HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite} 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:-loud-ams} DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams} 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 From efc933d5d0c3cbba056899645c5a796a1a569984 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 10:57:29 +0000 Subject: [PATCH 12/19] Extra tags and Social Card integration in the Infrastructure package (#28) This PR contains the work done to extends the `Page` protocol with the head tags that control search snippets and link previews: a meta description, a canonical URL, and a social card rendered as _Open Graph_ and _Twitter_ meta tags. All three are optional with nil defaults, so existing conformers compile and render unchanged. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/28 Co-authored-by: Javier Cicchelli --- .../Sources/Public/Protocols/Page.swift | 71 +++++++-- .../Sources/Public/Types/SocialCard.swift | 138 ++++++++++++++++++ .../Types/SocialCard/SocialCardImage.swift | 72 +++++++++ .../Types/SocialCard/SocialCardTag.swift | 55 +++++++ .../Cases/Public/Protocols/PageTests.swift | 50 +++++++ .../Cases/Public/Types/SocialCardTests.swift | 84 +++++++++++ .../Utils/Extensions/Tag+Constants.swift | 2 +- .../Tests/Utils/Pages/StubPage.swift | 23 ++- 8 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 7875f6b..7ab842e 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -4,8 +4,8 @@ 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 and stylesheet links followed by the metadata in the head, and the content followed by -/// the script tags in the body. +/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, 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 @@ -21,6 +21,9 @@ public protocol Page: HTMLDocument, Sendable { /// 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 } @@ -28,30 +31,41 @@ public protocol Page: HTMLDocument, Sendable { /// The locale the page content is localized to. var locale: Locale { get } - /// The markup placed in the document head after the ``stylesheets``: icon and manifest - /// links, extra meta tags, and the like. + /// 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 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, @@ -59,8 +73,9 @@ public extension Page { ))) {} } } - - /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. + + /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags (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. @@ -70,9 +85,35 @@ public extension Page { .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) + ) + } + } metadata - + for file in stylesheets { link( .rel(.stylesheet), @@ -83,5 +124,15 @@ public extension Page { ) } } + + /// The social card is omitted unless the page provides one. + var socialCard: SocialCard? { + nil + } + /// The summary is omitted unless the page provides one. + var summary: String? { + nil + } + } diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift new file mode 100644 index 0000000..101ef61 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -0,0 +1,138 @@ +/// 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: [SocialCardTag] { + let tags: [SocialCardTag?] = [ + SocialCardTag( + attribute: .property, + content: type, + name: .type + ), + siteName.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .siteName + ) + }, + SocialCardTag( + attribute: .property, + content: title, + name: .title + ), + summary.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .description + ) + }, + url.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .url + ) + }, + locale.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .locale + ) + }, + ] + (image?.tags ?? []) + [ + SocialCardTag( + attribute: .name, + content: 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..d00398f --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift @@ -0,0 +1,72 @@ +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: [SocialCardTag] { + let tags: [SocialCardTag?] = [ + SocialCardTag( + attribute: .property, + content: url, + name: .image + ), + SocialCardTag( + attribute: .property, + content: String(width), + name: .imageWidth + ), + SocialCardTag( + attribute: .property, + content: String(height), + name: .imageHeight + ), + alt.map { + SocialCardTag( + attribute: .property, + content: $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..414782b --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift @@ -0,0 +1,55 @@ +/// A head meta tag of a ``SocialCard``: which attribute keys it, and its name and content. +public struct SocialCardTag: Equatable, Sendable { + + // MARK: Properties + + /// The attribute the tag is keyed by. + public let attribute: Attribute + + /// The tag's value. + public let content: String + + /// The tag's name. + public let name: Name + +} + +// MARK: - Enumerations + +extension SocialCardTag { + + /// The meta attribute a ``SocialCardTag`` 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 ``SocialCardTag``, 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:width` tag, carrying the share image's width in pixels. + case imageWidth = "og:image:width" + /// The `og:image:height` tag, carrying the share image's height in pixels. + case imageHeight = "og:image:height" + /// 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" + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index 485d65e..b1a10af 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -47,6 +47,56 @@ struct PageTests { #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 `appends the version token to the asset URLs`() { let html = StubPage(assetVersion: "0123456789abcdef").render() 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..1282bb9 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -0,0 +1,84 @@ +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(attribute: .property, content: "website", name: .type), + .init(attribute: .property, content: "A Site", name: .siteName), + .init(attribute: .property, content: "A Title", name: .title), + .init(attribute: .property, content: "A summary.", name: .description), + .init(attribute: .property, content: "https://site.example/", name: .url), + .init(attribute: .property, content: "en", name: .locale), + .init(attribute: .property, content: "https://site.example/img/card.png", name: .image), + .init(attribute: .property, content: "2400", name: .imageWidth), + .init(attribute: .property, content: "1260", name: .imageHeight), + .init(attribute: .property, content: "An image.", name: .imageAlt), + .init(attribute: .name, content: "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(attribute: .property, content: "website", name: .type), + .init(attribute: .property, content: "A Title", name: .title), + .init(attribute: .name, content: "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(attribute: .property, content: "article", name: .type))) + #expect(card.tags.contains(.init(attribute: .name, content: "summary", name: .twitter))) + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift index 9fec14b..1117cd8 100644 --- a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -9,6 +9,6 @@ extension Tag { @Tag static var middleware: Tag /// Tests exercising a protocol scaffolding of the Infrastructure package. @Tag static var `protocol`: Tag - /// Tests exercising an internal type of the Infrastructure package. + /// 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 index eae187d..68c8ac0 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -10,9 +10,18 @@ struct StubPage: Page { /// 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 summary rendered in the document head, or `nil` to omit it. + let summary: String? + // MARK: Initializers /// Creates a stub page. @@ -20,12 +29,24 @@ struct StubPage: Page { /// - 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. + /// - summary: the summary rendered in the document head, or `nil` (the default) + /// to omit it. init( locale: Locale = .init(identifier: "en"), - assetVersion: String? = nil + assetVersion: String? = nil, + canonicalURL: String? = nil, + socialCard: SocialCard? = nil, + summary: String? = nil ) { self.assetVersion = assetVersion + self.canonicalURL = canonicalURL self.locale = locale + self.socialCard = socialCard + self.summary = summary } // MARK: Computed From 5f4316b85b59a88de151d2ed1860b3a4c0f7b335 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:15:27 +0000 Subject: [PATCH 13/19] Added the Utility package (#29) This PR contains the work done to create the new **Utility** package within the project, and also included in it the `NormalizeEmail` method, as it's not something that belongs to the **Infrastructure** package. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/29 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 8 ++- Packages/Utility/Package.swift | 31 ++++++++++ Packages/Utility/README.md | 31 ++++++++++ .../Public/Methods/NormalizeEmail.swift | 49 ++++++++++++++++ .../Public/Methods/NormalizeEmailTests.swift | 57 +++++++++++++++++++ .../Utils/Extensions/Tag+Constants.swift | 6 ++ Services/Website/Package.swift | 10 +++- Services/Website/Tests/Website.xctestplan | 7 +++ 8 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 Packages/Utility/Package.swift create mode 100644 Packages/Utility/README.md create mode 100644 Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift create mode 100644 Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift create mode 100644 Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index cade1d7..c7fdf23 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -8,13 +8,14 @@ The package provides, grouped by role: | 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 `SocialCardTag` meta tags it derives | | 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. +- **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`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. @@ -29,7 +30,8 @@ Sources/ │ ├── Methods/ FingerprintAssets │ ├── Middlewares/ the five HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController -│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ └── Types/ SocialCard, with its image and tag types in SocialCard/ └── Internal/ └── Types/ implementation details (FNV1aHash) Tests/ @@ -43,4 +45,4 @@ Every suite carries a tag naming the kind of API it exercises — `.asset`, `.ex ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). -- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- 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/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/Package.swift b/Services/Website/Package.swift index 71e1f67..f3539f9 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -20,6 +20,9 @@ let package = Package( ) ], dependencies: [ + .package( + path: "../../Packages/Infrastructure" + ), .package( path: "../../Packages/Localization" ), @@ -27,7 +30,7 @@ let package = Package( path: "../../Packages/Persistence" ), .package( - path: "../../Packages/Infrastructure" + path: "../../Packages/Utility" ), .package( url: "https://github.com/elementary-swift/elementary.git", @@ -79,9 +82,10 @@ let package = Package( .target( name: "WebsiteLibrary", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Localization"), .byName(name: "Persistence"), - .byName(name: "Infrastructure"), + .byName(name: "Utility"), .product( name: "Configuration", package: "swift-configuration" @@ -121,8 +125,8 @@ let package = Package( .testTarget( name: "WebsiteLibraryTests", dependencies: [ - .byName(name: "Persistence"), .byName(name: "Infrastructure"), + .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index b82c471..a16d3f0 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -52,6 +52,13 @@ "identifier" : "InfrastructureTests", "name" : "InfrastructureTests" } + }, + { + "target" : { + "containerPath" : "container:..\/..\/Packages\/Utility", + "identifier" : "UtilityTests", + "name" : "UtilityTests" + } } ], "version" : 1 From 5c9fde9d41ffd4af36b2fe7a87afd91b75d82f31 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:25:36 +0000 Subject: [PATCH 14/19] Social Card tweaks in the Infrastructure package. (#30) This PR contains the work done to address certain tweaks in the newly-introduced _Social Card_ types in the **Infrastructure** package. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/30 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 2 +- .../Sources/Public/Types/SocialCard.swift | 54 +++------------ .../Types/SocialCard/SocialCardImage.swift | 30 ++------- .../Types/SocialCard/SocialCardTag.swift | 65 ++++++++++++++----- .../Cases/Public/Types/SocialCardTests.swift | 39 ++++++----- 5 files changed, 88 insertions(+), 102 deletions(-) diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index c7fdf23..5028528 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -8,7 +8,7 @@ The package provides, grouped by role: | 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 `SocialCardTag` meta tags it derives | +| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift index 101ef61..0fe4a74 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -70,52 +70,16 @@ public struct SocialCard: Sendable { /// 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: [SocialCardTag] { - let tags: [SocialCardTag?] = [ - SocialCardTag( - attribute: .property, - content: type, - name: .type - ), - siteName.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .siteName - ) - }, - SocialCardTag( - attribute: .property, - content: title, - name: .title - ), - summary.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .description - ) - }, - url.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .url - ) - }, - locale.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .locale - ) - }, + 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 ?? []) + [ - SocialCardTag( - attribute: .name, - content: style.rawValue, - name: .twitter - ), + Tag(style.rawValue, name: .twitter), ] return tags.compactMap { $0 } diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift index d00398f..43f8575 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift @@ -39,30 +39,12 @@ extension SocialCard { // 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: [SocialCardTag] { - let tags: [SocialCardTag?] = [ - SocialCardTag( - attribute: .property, - content: url, - name: .image - ), - SocialCardTag( - attribute: .property, - content: String(width), - name: .imageWidth - ), - SocialCardTag( - attribute: .property, - content: String(height), - name: .imageHeight - ), - alt.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .imageAlt - ) - }, + 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 index 414782b..ec446a5 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift @@ -1,24 +1,44 @@ -/// A head meta tag of a ``SocialCard``: which attribute keys it, and its name and content. -public struct SocialCardTag: Equatable, Sendable { +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 + // MARK: Properties - /// The attribute the tag is keyed by. - public let attribute: Attribute + /// The tag's value. + public let content: String - /// The tag's value. - public let content: String + /// The tag's name. + public let name: Name + + // MARK: Initializers - /// The tag's name. - public let name: Name + /// 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 SocialCardTag { - - /// The meta attribute a ``SocialCardTag`` is keyed by, named by its raw value. +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 @@ -26,7 +46,7 @@ extension SocialCardTag { case property } - /// The name of a ``SocialCardTag``, carried in its raw value. + /// 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" @@ -34,10 +54,10 @@ extension SocialCardTag { 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:width` tag, carrying the share image's width in pixels. - case imageWidth = "og:image:width" /// 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. @@ -51,5 +71,18 @@ extension SocialCardTag { /// 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/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift index 1282bb9..6849302 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -27,17 +27,17 @@ struct SocialCardTests { ) #expect(card.tags == [ - .init(attribute: .property, content: "website", name: .type), - .init(attribute: .property, content: "A Site", name: .siteName), - .init(attribute: .property, content: "A Title", name: .title), - .init(attribute: .property, content: "A summary.", name: .description), - .init(attribute: .property, content: "https://site.example/", name: .url), - .init(attribute: .property, content: "en", name: .locale), - .init(attribute: .property, content: "https://site.example/img/card.png", name: .image), - .init(attribute: .property, content: "2400", name: .imageWidth), - .init(attribute: .property, content: "1260", name: .imageHeight), - .init(attribute: .property, content: "An image.", name: .imageAlt), - .init(attribute: .name, content: "summary_large_image", name: .twitter), + .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), ]) } @@ -46,9 +46,9 @@ struct SocialCardTests { let card = SocialCard(title: "A Title") #expect(card.tags == [ - .init(attribute: .property, content: "website", name: .type), - .init(attribute: .property, content: "A Title", name: .title), - .init(attribute: .name, content: "summary_large_image", name: .twitter), + .init("website", name: .type), + .init("A Title", name: .title), + .init("summary_large_image", name: .twitter), ]) } @@ -77,8 +77,15 @@ struct SocialCardTests { style: .summary ) - #expect(card.tags.contains(.init(attribute: .property, content: "article", name: .type))) - #expect(card.tags.contains(.init(attribute: .name, content: "summary", name: .twitter))) + #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) } } From 1d1f0a9dc9ddffd9067cb984e5cd432de666cab0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:53:36 +0000 Subject: [PATCH 15/19] Utility package docker fix in the Website service target (#31) This PR contains the work done to included the Utility package into the Docker deployment process in the Website service target. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/31 Co-authored-by: Javier Cicchelli --- Services/Website/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 9693882..0714bda 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -48,6 +48,7 @@ WORKDIR /build 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 @@ -56,6 +57,7 @@ RUN swift package --package-path ./Services/Website resolve 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 From b680ae0689b337929087c49afd4fc057795ba161 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 17:21:16 +0000 Subject: [PATCH 16/19] Structured Data support for the Page protocol in the Infrastructure package (#32) This PR contains the work done to introduce a `StructuredData` type that pages use to describe themselves to search engines as schema.org JSON-LD, and wires it into the Page protocol so the payload renders automatically in the document head. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/32 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 8 +- .../Extensions/String+Separators.swift | 5 + .../Sources/Public/Protocols/Page.swift | 28 ++- .../Sources/Public/Types/StructuredData.swift | 106 ++++++++++++ .../StructuredData/StructuredDataNode.swift | 92 ++++++++++ .../StructuredDataProperty.swift | 82 +++++++++ .../StructuredData/StructuredDataValue.swift | 67 ++++++++ .../Cases/Public/Protocols/PageTests.swift | 25 +++ .../Public/Types/StructuredDataTests.swift | 159 ++++++++++++++++++ .../Tests/Utils/Pages/StubPage.swift | 7 + 10 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 5028528..b724c15 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -9,14 +9,15 @@ The package provides, grouped by role: | 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`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card. -- **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. +- **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 @@ -31,8 +32,9 @@ Sources/ │ ├── Middlewares/ the five HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController │ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse -│ └── Types/ SocialCard, with its image and tag types in SocialCard/ +│ └── 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 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/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 7ab842e..30c25e1 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -4,8 +4,8 @@ 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, and the metadata followed by the -/// stylesheet links in the head, and the content followed by the script tags in the body. +/// 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 @@ -42,6 +42,9 @@ public protocol Page: HTMLDocument, Sendable { /// 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 } @@ -74,11 +77,14 @@ public extension Page { } } - /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags (when provided), and the ``metadata`` - /// followed by the ``stylesheets`` links, placed in the document head. + /// 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( @@ -112,6 +118,15 @@ public extension Page { } } + if let structuredData { + script(.custom( + name: "type", + value: "application/ld+json" + )) { + HTMLRaw(structuredData.payload) + } + } + metadata for file in stylesheets { @@ -130,6 +145,11 @@ public extension Page { 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/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/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index b1a10af..e0fc405 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -97,6 +97,31 @@ struct PageTests { #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() 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/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift index 68c8ac0..ffc755e 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -19,6 +19,9 @@ struct StubPage: Page { /// 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? @@ -33,6 +36,8 @@ struct StubPage: Page { /// 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( @@ -40,12 +45,14 @@ struct StubPage: Page { 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 } From 2c571233bc22254ee0b364af9ca0c55446a5c090 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 23:06:22 +0200 Subject: [PATCH 17/19] Renamed the Error page in the Website library target as NotFound. --- .../Library/Catalogs/Localizable.xcstrings | 66 +++++++++---------- .../{ErrorPage.swift => NotFoundPage.swift} | 14 ++-- .../NotFoundMiddleware+Defaults.swift | 2 +- ...ageTests.swift => NotFoundPageTests.swift} | 6 +- 4 files changed, 44 insertions(+), 44 deletions(-) rename Services/Website/Sources/Library/Internal/Pages/{ErrorPage.swift => NotFoundPage.swift} (80%) rename Services/Website/Tests/Library/Cases/Internal/Pages/{ErrorPageTests.swift => NotFoundPageTests.swift} (89%) 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/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift similarity index 80% rename from Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift rename to Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift index 2b19f7b..f49730c 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift @@ -4,7 +4,7 @@ import Infrastructure import Localization /// The HTML page rendered for a not-found response, with its text localized to a given locale. -struct ErrorPage { +struct NotFoundPage { // MARK: Properties @@ -36,29 +36,29 @@ struct ErrorPage { // MARK: Page -extension ErrorPage: Page { +extension NotFoundPage: Page { // MARK: Properties var content: some HTML { h1 { - localize("error.heading", locale: locale) + localize("notFound.heading", locale: locale) } p { - localize("error.message", locale: locale) + localize("notFound.message", locale: locale) } } var scripts: [any Asset] { - [StaticFile.error, StaticFile.shared] + [StaticFile.notFound, StaticFile.shared] } var stylesheets: [any Asset] { - [StaticFile.shared, StaticFile.error] + [StaticFile.shared, StaticFile.notFound] } var title: String { - localize("error.title", locale: locale) + localize("notFound.title", locale: locale) } } diff --git a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift index 545dd1f..5c850f9 100644 --- a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift +++ b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift @@ -11,7 +11,7 @@ public extension NotFoundMiddleware { assetVersion: String? = nil ) { self.init(bundle: .module) { - ErrorPage( + NotFoundPage( locale: $0, assetVersion: assetVersion ) diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift similarity index 89% rename from Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift rename to Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift index 4a2771c..d29b2d0 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift @@ -5,16 +5,16 @@ import Testing @testable import WebsiteLibrary @Suite( - "ErrorPage page", + "NotFoundPage page", .tags(.page) ) -struct ErrorPageTests { +struct NotFoundPageTests { // MARK: Functional tests @Test func `renders its markup`() { - let html = ErrorPage( + let html = NotFoundPage( locale: .init(identifier: "en") ).render() From a056f7f69ac9044dd5b66c54d16b6f2d55dd8f9f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 23:07:04 +0200 Subject: [PATCH 18/19] Renamed the "error" static assets at the Resources folder in the Website service target as "not-found". --- .../Resources/Static/css/{error.css => not-found.css} | 0 .../Resources/Static/js/{error.js => not-found.js} | 0 .../Library/Internal/Enumerations/StaticFile.swift | 10 +++++----- Services/Website/Tests/App/AppTests.swift | 2 +- .../Cases/Internal/Enumerations/StaticFileTests.swift | 4 ++-- .../Cases/Internal/Pages/NotFoundPageTests.swift | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) rename Services/Website/Resources/Static/css/{error.css => not-found.css} (100%) rename Services/Website/Resources/Static/js/{error.js => not-found.js} (100%) 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/js/error.js b/Services/Website/Resources/Static/js/not-found.js similarity index 100% rename from Services/Website/Resources/Static/js/error.js rename to Services/Website/Resources/Static/js/not-found.js diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 3196f8f..945ca74 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -7,8 +7,6 @@ import Infrastructure enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon - /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. - case error /// The `favicon.ico` icon. case favicon /// The `icon.svg` icon. @@ -19,6 +17,8 @@ enum StaticFile: Asset, CaseIterable { 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 robots /// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages. @@ -41,8 +41,8 @@ extension StaticFile { case .appleTouchIcon, .icon192, .icon512: [.png] - case .error, - .index, + case .index, + .notFound, .shared: [.css, .js] case .favicon: [.ico] case .icon: [.svg] @@ -56,12 +56,12 @@ extension StaticFile { var fileName: String { switch self { case .appleTouchIcon: "apple-touch-icon" - case .error: "error" 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" diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 70fce3c..846ccf2 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -268,7 +268,7 @@ struct AppTests { ) { response in let body = String(buffer: response.body) - #expect(body.contains("/css/error.css?v=")) + #expect(body.contains("/css/not-found.css?v=")) #expect(body.contains("/js/shared.js?v=")) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 0c141e5..b675544 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -54,12 +54,12 @@ private extension StaticFileTests { static let fileExtensions: [[AssetExtension]] = [ [.png], - [.css, .js], [.ico], [.svg], [.png], [.png], [.css, .js], + [.css, .js], [.txt], [.css, .js], [.webmanifest], @@ -67,12 +67,12 @@ private extension StaticFileTests { ] static let fileNames: [String] = [ "apple-touch-icon", - "error", "favicon", "icon", "icon-192", "icon-512", "index", + "not-found", "robots", "shared", "site", diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift index d29b2d0..21786cd 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift @@ -23,8 +23,8 @@ struct NotFoundPageTests { #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/shared.css")) - #expect(html.contains("/css/error.css")) - #expect(html.contains("/js/error.js")) + #expect(html.contains("/css/not-found.css")) + #expect(html.contains("/js/not-found.js")) #expect(html.contains("/js/shared.js")) } From 64d2d5943af7ca6a603b7fd23af55c737a118147 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 2 Aug 2026 00:58:11 +0200 Subject: [PATCH 19/19] Updated the documentation of the README file in the Website service target. --- Services/Website/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Services/Website/README.md b/Services/Website/README.md index ec2eaaa..f2325ac 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -7,7 +7,7 @@ The service: - 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`; the production image ships minified copies (see [Static assets](#static-assets)). -- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file. +- Returns a custom 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,12 +22,13 @@ 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 three 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). @@ -38,7 +39,7 @@ LogRequestsMiddleware → 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 404 page on .notFound) + → 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) @@ -184,7 +185,7 @@ 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 `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`, and `LocalizationTests`. +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