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