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