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