Renamed the Web package as Infrastructure (#25)
This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: rock-n-code/loud-amsterdam#25 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -16,6 +16,13 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
path: "../Localization"
|
||||||
|
),
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/elementary-swift/elementary.git",
|
||||||
|
from: "0.6.0"
|
||||||
|
),
|
||||||
.package(
|
.package(
|
||||||
url: "https://github.com/hummingbird-project/hummingbird.git",
|
url: "https://github.com/hummingbird-project/hummingbird.git",
|
||||||
from: "2.25.0"
|
from: "2.25.0"
|
||||||
@@ -25,6 +32,11 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "Infrastructure",
|
name: "Infrastructure",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Localization"),
|
||||||
|
.product(
|
||||||
|
name: "Elementary",
|
||||||
|
package: "elementary"
|
||||||
|
),
|
||||||
.product(
|
.product(
|
||||||
name: "Hummingbird",
|
name: "Hummingbird",
|
||||||
package: "hummingbird"
|
package: "hummingbird"
|
||||||
@@ -36,12 +48,21 @@ let package = Package(
|
|||||||
name: "InfrastructureTests",
|
name: "InfrastructureTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.byName(name: "Infrastructure"),
|
.byName(name: "Infrastructure"),
|
||||||
|
.product(
|
||||||
|
name: "Elementary",
|
||||||
|
package: "elementary"
|
||||||
|
),
|
||||||
.product(
|
.product(
|
||||||
name: "HummingbirdTesting",
|
name: "HummingbirdTesting",
|
||||||
package: "hummingbird"
|
package: "hummingbird"
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
path: "Tests"
|
path: "Tests",
|
||||||
|
resources: [
|
||||||
|
// Copied verbatim rather than processed: the String Catalog is read as raw JSON at
|
||||||
|
// runtime so it resolves identically on Darwin and Linux (which cannot compile it).
|
||||||
|
.copy("Catalogs/Localizable.xcstrings")
|
||||||
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Infrastructure
|
||||||
|
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The package provides, grouped by role:
|
||||||
|
|
||||||
|
| Role | Types |
|
||||||
|
| --- | --- |
|
||||||
|
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||||
|
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||||
|
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||||
|
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||||
|
| Contexts | `LocalizedRequestContext` |
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
|
||||||
|
|
||||||
|
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors.
|
||||||
|
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow.
|
||||||
|
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
Sources are split by visibility, then by kind, one type per file:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/Public/<Kind>/ public API (Protocols, Middlewares, Responses, …)
|
||||||
|
Sources/Internal/<Kind>/ implementation details (e.g. FNV1aHash)
|
||||||
|
Tests/Cases/… mirrors the source layout
|
||||||
|
Tests/Utils/… stubs and test-only extensions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
|
- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Hashes bytes with the FNV-1a 64-bit algorithm.
|
||||||
|
///
|
||||||
|
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it
|
||||||
|
/// suits values that must agree between instances and survive restarts: the asset version token
|
||||||
|
/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`).
|
||||||
|
/// It is not cryptographic — a collision only risks serving a stale cached asset, not security.
|
||||||
|
struct FNV1aHash {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The running hash value.
|
||||||
|
private var hash: UInt64
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a hasher at the FNV-1a offset basis.
|
||||||
|
init() {
|
||||||
|
self.hash = 0xcbf2_9ce4_8422_2325
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The hash of everything combined so far, as a fixed-width, 16-character hexadecimal token.
|
||||||
|
///
|
||||||
|
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
|
||||||
|
var digest: String {
|
||||||
|
String(
|
||||||
|
format: "%016llx",
|
||||||
|
hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
/// Folds the given bytes into the hash.
|
||||||
|
/// - Parameter bytes: the bytes to fold in.
|
||||||
|
mutating func combine(
|
||||||
|
_ bytes: some Sequence<UInt8>
|
||||||
|
) {
|
||||||
|
for byte in bytes {
|
||||||
|
hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/// A file extension used by an ``Asset``.
|
||||||
|
///
|
||||||
|
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its
|
||||||
|
/// file name when resolving paths.
|
||||||
|
public enum AssetExtension: String, Sendable {
|
||||||
|
/// A Cascading Style Sheets file.
|
||||||
|
case css
|
||||||
|
/// A JavaScript file.
|
||||||
|
case js
|
||||||
|
/// A Portable Network Graphics image.
|
||||||
|
case png
|
||||||
|
/// A Windows icon image.
|
||||||
|
case ico
|
||||||
|
/// A Scalable Vector Graphics image.
|
||||||
|
case svg
|
||||||
|
/// A plain text file.
|
||||||
|
case txt
|
||||||
|
/// A web application manifest file.
|
||||||
|
case webmanifest
|
||||||
|
/// An Extensible Markup Language file.
|
||||||
|
case xml
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
public extension AssetExtension {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The file's content type.
|
||||||
|
var contentType: String {
|
||||||
|
switch self {
|
||||||
|
case .css: "text/css"
|
||||||
|
case .js: "text/javascript"
|
||||||
|
case .png: "image/png"
|
||||||
|
case .ico: "image/vnd.microsoft.icon"
|
||||||
|
case .svg: "image/svg+xml"
|
||||||
|
case .txt: "text/plain"
|
||||||
|
case .webmanifest: "application/manifest+json"
|
||||||
|
case .xml: "application/xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sub-directory within the static root that holds files with this extension, if any.
|
||||||
|
var subdirectory: String? {
|
||||||
|
switch self {
|
||||||
|
case .css: "css"
|
||||||
|
case .js: "js"
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
import HTTPTypes
|
import HTTPTypes
|
||||||
|
|
||||||
extension HTTPField.Name {
|
public extension HTTPField.Name {
|
||||||
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
|
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||||
static let permissionsPolicy = Self("Permissions-Policy")!
|
static let permissionsPolicy = Self("Permissions-Policy")!
|
||||||
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||||
static let referrerPolicy = Self("Referrer-Policy")!
|
static let referrerPolicy = Self("Referrer-Policy")!
|
||||||
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||||
static let frameOptions = Self("X-Frame-Options")!
|
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")!
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
extension String {
|
||||||
|
/// A namespace for the security headers' default configuration values.
|
||||||
|
///
|
||||||
|
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
|
||||||
|
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
|
||||||
|
public enum Security {
|
||||||
|
/// The default `Content-Security-Policy`.
|
||||||
|
///
|
||||||
|
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins
|
||||||
|
/// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing
|
||||||
|
/// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link
|
||||||
|
/// external stylesheets.
|
||||||
|
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||||
|
public static let contentTypeOptions = "nosniff"
|
||||||
|
/// The default `X-Frame-Options` (forbids framing the page).
|
||||||
|
public static let frameOptions = "DENY"
|
||||||
|
/// The default `Referrer-Policy`.
|
||||||
|
public static let referrerPolicy = "strict-origin-when-cross-origin"
|
||||||
|
/// The default `Permissions-Policy` (denies access to powerful browser features a static site does not use).
|
||||||
|
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
/// Derives a version token from the contents of the static files directory.
|
||||||
|
///
|
||||||
|
/// The token folds every file under the directory — its relative path and its bytes, in a stable order — into one FNV-1a digest, so it changes whenever any
|
||||||
|
/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=<token>`), 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+7
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import HTTPTypes
|
import HTTPTypes
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import Localization
|
import Localization
|
||||||
@@ -19,9 +20,12 @@ public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
|||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a localization middleware that negotiates against the module's String Catalog languages.
|
/// Creates a localization middleware that negotiates against the given bundle's String Catalog languages.
|
||||||
public init() {
|
/// - Parameter bundle: the bundle whose String Catalog names the supported languages.
|
||||||
self.negotiate = .init(bundle: .module)
|
public init(
|
||||||
|
bundle: Bundle
|
||||||
|
) {
|
||||||
|
self.negotiate = .init(bundle: bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+20
-13
@@ -1,11 +1,12 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
|
||||||
/// Serves a custom error page for requests that match neither a route nor a static file.
|
/// 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
|
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
|
||||||
/// that bubbles up when no file exists for the requested path and responds with the rendered
|
/// 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
|
||||||
/// ``ErrorPage`` and a `404 Not Found` status. The page is served in the language stored on the
|
/// ``LocalizationMiddleware``, falling back to the default language.
|
||||||
/// context by ``LocalizationMiddleware``, falling back to the default language.
|
|
||||||
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -16,12 +17,18 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a not-found middleware.
|
/// Creates a not-found middleware.
|
||||||
public init() {
|
/// - Parameters:
|
||||||
self.responses = .init(
|
/// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
|
||||||
status: .notFound
|
/// - document: builds the error page to render for a given locale.
|
||||||
|
public init<Document: HTMLDocument>(
|
||||||
|
bundle: Bundle,
|
||||||
|
document: (Locale) -> Document
|
||||||
) {
|
) {
|
||||||
ErrorPage(locale: $0)
|
self.responses = .init(
|
||||||
}
|
bundle: bundle,
|
||||||
|
status: .notFound,
|
||||||
|
document: document
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -32,15 +39,14 @@ extension NotFoundMiddleware: RouterMiddleware {
|
|||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
|
|
||||||
/// Passes the request down the chain, rendering the error page if it results in a not-found
|
/// Passes the request down the chain, rendering the error page if it results in a not-found response.
|
||||||
/// response.
|
|
||||||
///
|
///
|
||||||
/// Any error other than `.notFound` is rethrown unchanged.
|
/// Any error other than `.notFound` is rethrown unchanged.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - request: the incoming request.
|
/// - request: the incoming request.
|
||||||
/// - context: the context the request is resolved against.
|
/// - context: the context the request is resolved against.
|
||||||
/// - next: the next responder in the middleware chain.
|
/// - 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.
|
/// - Throws: any non-not-found error thrown downstream.
|
||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
@@ -59,7 +65,8 @@ extension NotFoundMiddleware: RouterMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return responses.response(
|
return responses.response(
|
||||||
for: context.language
|
for: context.language,
|
||||||
|
request: request
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Context: RequestContext>: 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"
|
||||||
|
}
|
||||||
+18
-3
@@ -4,7 +4,7 @@ import Hummingbird
|
|||||||
/// Stamps a set of security-related HTTP headers onto every response.
|
/// 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
|
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
|
||||||
/// response bubbles back up — the rendered landing page, the ``ErrorPage`` produced by
|
/// response bubbles back up — the rendered pages, the error page produced by
|
||||||
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies
|
/// ``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 strict, hardened interpretation of the content instead of its lenient legacy defaults.
|
||||||
///
|
///
|
||||||
@@ -40,6 +40,9 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
|||||||
/// Passes the request down the chain and stamps the configured security headers onto the
|
/// Passes the request down the chain and stamps the configured security headers onto the
|
||||||
/// response on the way back up.
|
/// response on the way back up.
|
||||||
///
|
///
|
||||||
|
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the
|
||||||
|
/// controllers) are converted to their response here rather than left to the router: the router
|
||||||
|
/// converts them above the middleware chain, where the response would escape these headers.
|
||||||
/// Existing values for the same header names are replaced so downstream middleware cannot leave
|
/// Existing values for the same header names are replaced so downstream middleware cannot leave
|
||||||
/// a weaker policy in place.
|
/// a weaker policy in place.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -47,13 +50,25 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
|||||||
/// - context: the context the request is resolved against.
|
/// - context: the context the request is resolved against.
|
||||||
/// - next: the next responder in the middleware chain.
|
/// - next: the next responder in the middleware chain.
|
||||||
/// - Returns: the downstream response with the security headers applied.
|
/// - 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(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: (Request, Context) async throws -> Response
|
||||||
) 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 {
|
for field in fields {
|
||||||
response.headers[field.name] = field.value
|
response.headers[field.name] = field.value
|
||||||
@@ -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<Context: RequestContext>: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/// An asset shipped with a website: a file stored under the static files root and served by
|
||||||
|
/// Hummingbird's `FileMiddleware` middleware.
|
||||||
|
///
|
||||||
|
/// A conforming asset supplies its file name and the extensions it is available with, each
|
||||||
|
/// resolving to its own file; the protocol derives the paths from them: the file's path within
|
||||||
|
/// the static files root and the URL path it is served at, optionally versioned to bust caches.
|
||||||
|
public protocol Asset: Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The file extensions the asset is available with.
|
||||||
|
var fileExtensions: [AssetExtension] { get }
|
||||||
|
|
||||||
|
/// The asset's file name, without extension.
|
||||||
|
var fileName: String { get }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
public extension Asset {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Resolves the asset's path against the given base directory.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - basePath: the directory the static files are served from.
|
||||||
|
/// - fileExtension: the extension of the file to resolve.
|
||||||
|
/// - Returns: the path to the file, relative to the `basePath` path.
|
||||||
|
func path(
|
||||||
|
relativeTo basePath: String,
|
||||||
|
for fileExtension: AssetExtension
|
||||||
|
) -> String {
|
||||||
|
let relativePath = relativePath(for: fileExtension)
|
||||||
|
|
||||||
|
guard !basePath.isEmpty else {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(basePath)/\(relativePath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`).
|
||||||
|
///
|
||||||
|
/// This also matches the URL path the file is served at by `FileMiddleware`.
|
||||||
|
///
|
||||||
|
/// - Parameter fileExtension: the extension of the file to resolve.
|
||||||
|
/// - Returns: the path to the file, relative to the static files root.
|
||||||
|
func relativePath(
|
||||||
|
for fileExtension: AssetExtension
|
||||||
|
) -> String {
|
||||||
|
let file = "\(fileName).\(fileExtension.rawValue)"
|
||||||
|
|
||||||
|
return fileExtension.subdirectory
|
||||||
|
.map { "\($0)/\(file)" } ?? file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`).
|
||||||
|
///
|
||||||
|
/// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`):
|
||||||
|
/// `FileMiddleware` ignores the query when resolving the file, while caches key on the full
|
||||||
|
/// URL, so a deploy that changes the assets busts every cached copy at once.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - fileExtension: the extension of the file to resolve.
|
||||||
|
/// - version: the version token to append, or `nil` to leave the URL unversioned.
|
||||||
|
/// - Returns: the path to use in `href` and `src` attributes.
|
||||||
|
func urlPath(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
version: String? = nil
|
||||||
|
) -> String {
|
||||||
|
let path = "/\(relativePath(for: fileExtension))"
|
||||||
|
|
||||||
|
guard let version, !version.isEmpty else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(path)?v=\(version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
/// A request context that carries the language negotiated for the request.
|
||||||
|
///
|
||||||
|
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language`
|
||||||
|
/// header and stores it here, so downstream controllers and middleware can serve the matching
|
||||||
|
/// localization without re-reading the header.
|
||||||
|
public protocol LocalizedRequestContext: RequestContext {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The language identifier negotiated for the request.
|
||||||
|
var language: String { get set }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
|
||||||
|
///
|
||||||
|
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
|
||||||
|
/// rest of the document around them: the viewport declaration and stylesheet links followed by the metadata in the head, and the content followed by
|
||||||
|
/// the script tags in the body.
|
||||||
|
public protocol Page: HTMLDocument, Sendable {
|
||||||
|
|
||||||
|
// MARK: Associated types
|
||||||
|
|
||||||
|
/// The type of the page's markup.
|
||||||
|
associatedtype Content: HTML
|
||||||
|
|
||||||
|
/// The type of the page's head metadata markup.
|
||||||
|
associatedtype Metadata: HTML
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
|
var assetVersion: String? { get }
|
||||||
|
|
||||||
|
/// The page's markup, rendered before the ``scripts``.
|
||||||
|
@HTMLBuilder
|
||||||
|
var content: Content { get }
|
||||||
|
|
||||||
|
/// The locale the page content is localized to.
|
||||||
|
var locale: Locale { get }
|
||||||
|
|
||||||
|
/// The markup placed in the document head after the ``stylesheets``: icon and manifest
|
||||||
|
/// links, extra meta tags, and the like.
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: Metadata { get }
|
||||||
|
|
||||||
|
/// The scripts loaded at the end of the document body, in order.
|
||||||
|
var scripts: [any Asset] { get }
|
||||||
|
|
||||||
|
/// The stylesheets linked in the document head, in order.
|
||||||
|
var stylesheets: [any Asset] { get }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
public extension Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The page ``content`` followed by its ``scripts``.
|
||||||
|
@HTMLBuilder
|
||||||
|
var body: some HTML {
|
||||||
|
content
|
||||||
|
|
||||||
|
for file in scripts {
|
||||||
|
script(.src(file.urlPath(
|
||||||
|
for: .js,
|
||||||
|
version: assetVersion
|
||||||
|
))) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head.
|
||||||
|
///
|
||||||
|
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already
|
||||||
|
/// emits `<meta charset="UTF-8">` before this markup, and HTML5 allows only one.
|
||||||
|
@HTMLBuilder
|
||||||
|
var head: some HTML {
|
||||||
|
meta(
|
||||||
|
.name(.viewport),
|
||||||
|
.content("width=device-width, initial-scale=1")
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata
|
||||||
|
|
||||||
|
for file in stylesheets {
|
||||||
|
link(
|
||||||
|
.rel(.stylesheet),
|
||||||
|
.href(file.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: assetVersion
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+21
-10
@@ -10,11 +10,11 @@ import Localization
|
|||||||
/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by
|
/// 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
|
/// 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.
|
/// shared caches key on the negotiated language instead of serving one language to everyone.
|
||||||
struct LocalizedHTMLCollectionResponse: Sendable {
|
public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// 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
|
private let list: LanguageList
|
||||||
|
|
||||||
/// The pre-rendered responses, keyed by language identifier.
|
/// The pre-rendered responses, keyed by language identifier.
|
||||||
@@ -24,13 +24,15 @@ struct LocalizedHTMLCollectionResponse: Sendable {
|
|||||||
|
|
||||||
/// Renders the document once per supported language.
|
/// Renders the document once per supported language.
|
||||||
/// - Parameters:
|
/// - 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`.
|
/// - status: the status applied to every response. Defaults to `.ok`.
|
||||||
/// - document: builds the document to render for a given locale.
|
/// - document: builds the document to render for a given locale.
|
||||||
init<Document: HTMLDocument>(
|
public init<Document: HTMLDocument>(
|
||||||
|
bundle: Bundle,
|
||||||
status: HTTPResponse.Status = .ok,
|
status: HTTPResponse.Status = .ok,
|
||||||
document: (Locale) -> Document
|
document: (Locale) -> Document
|
||||||
) {
|
) {
|
||||||
self.list = .init(bundle: .module)
|
self.list = .init(bundle: bundle)
|
||||||
self.responses = list.all
|
self.responses = list.all
|
||||||
.reduce(into: [:]) { responses, language in
|
.reduce(into: [:]) { responses, language in
|
||||||
responses[language] = CachedHTMLResponse(
|
responses[language] = CachedHTMLResponse(
|
||||||
@@ -39,7 +41,9 @@ struct LocalizedHTMLCollectionResponse: Sendable {
|
|||||||
.contentLanguage: language,
|
.contentLanguage: language,
|
||||||
.vary: "Accept-Language",
|
.vary: "Accept-Language",
|
||||||
],
|
],
|
||||||
document: document(.init(identifier: language))
|
document: document(.init(
|
||||||
|
identifier: language
|
||||||
|
))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,19 +51,26 @@ struct LocalizedHTMLCollectionResponse: Sendable {
|
|||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
/// Builds the response for the given language, falling back to the default language.
|
/// Builds the response for the given language, falling back to the default language.
|
||||||
/// - Parameter language: the negotiated language identifier.
|
/// - Parameters:
|
||||||
|
/// - language: the negotiated language identifier.
|
||||||
|
/// - request: the request the response answers, consulted for conditional revalidation.
|
||||||
/// - Returns: the cached response for the language, the default language's response when the
|
/// - 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.
|
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
|
||||||
func response(
|
public func response(
|
||||||
for language: String
|
for language: String,
|
||||||
|
request: Request
|
||||||
) -> Response {
|
) -> Response {
|
||||||
guard
|
guard
|
||||||
let response = responses[language] ?? responses[list.default]
|
let response = responses[language] ?? responses[list.default]
|
||||||
else {
|
else {
|
||||||
return .init(status: .internalServerError)
|
return .init(
|
||||||
|
status: .internalServerError
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.response()
|
return response.response(
|
||||||
|
for: request
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("FNV1aHash type")
|
||||||
|
struct FNV1aHashTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
("", "cbf29ce484222325"),
|
||||||
|
("a", "af63dc4c8601ec8c"),
|
||||||
|
("b", "af63df4c8601f1a5"),
|
||||||
|
("foobar", "85944171f73967e8"),
|
||||||
|
])
|
||||||
|
func `matches the published FNV-1a 64-bit test vectors`(
|
||||||
|
input: String,
|
||||||
|
digest: String
|
||||||
|
) {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
hash.combine(input.utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest == digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `pads the digest to sixteen characters`() {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
// "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep.
|
||||||
|
hash.combine("aa".utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest == "089c4307b54596b7")
|
||||||
|
#expect(hash.digest.count == 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `hashes incrementally combined bytes as one stream`() {
|
||||||
|
var combined = FNV1aHash()
|
||||||
|
var whole = FNV1aHash()
|
||||||
|
|
||||||
|
combined.combine("foo".utf8)
|
||||||
|
combined.combine("bar".utf8)
|
||||||
|
whole.combine("foobar".utf8)
|
||||||
|
|
||||||
|
#expect(combined.digest == whole.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `distinguishes the order of the combined bytes`() {
|
||||||
|
var forward = FNV1aHash()
|
||||||
|
var backward = FNV1aHash()
|
||||||
|
|
||||||
|
forward.combine("ab".utf8)
|
||||||
|
backward.combine("ba".utf8)
|
||||||
|
|
||||||
|
#expect(forward.digest != backward.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `digests without consuming the running hash`() {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
hash.combine("foo".utf8)
|
||||||
|
|
||||||
|
let first = hash.digest
|
||||||
|
|
||||||
|
#expect(hash.digest == first)
|
||||||
|
|
||||||
|
hash.combine("bar".utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest != first)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("AssetExtension enumeration")
|
||||||
|
struct AssetExtensionTests {
|
||||||
|
|
||||||
|
// MARK: Computed tests
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
Self.extensions,
|
||||||
|
Self.contentTypes
|
||||||
|
))
|
||||||
|
func `content type`(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
expects contentType: String
|
||||||
|
) {
|
||||||
|
#expect(fileExtension.contentType == contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
Self.extensions,
|
||||||
|
Self.subdirectories
|
||||||
|
))
|
||||||
|
func `subdirectory`(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
expects subdirectory: String?
|
||||||
|
) {
|
||||||
|
#expect(fileExtension.subdirectory == subdirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension AssetExtensionTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
static let extensions: [AssetExtension] = [
|
||||||
|
.css,
|
||||||
|
.js,
|
||||||
|
.png,
|
||||||
|
.ico,
|
||||||
|
.svg,
|
||||||
|
.txt,
|
||||||
|
.webmanifest,
|
||||||
|
.xml
|
||||||
|
]
|
||||||
|
static let contentTypes: [String] = [
|
||||||
|
"text/css",
|
||||||
|
"text/javascript",
|
||||||
|
"image/png",
|
||||||
|
"image/vnd.microsoft.icon",
|
||||||
|
"image/svg+xml",
|
||||||
|
"text/plain",
|
||||||
|
"application/manifest+json",
|
||||||
|
"application/xml"
|
||||||
|
]
|
||||||
|
static let subdirectories: [String?] = [
|
||||||
|
"css",
|
||||||
|
"js",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("FingerprintAssets method")
|
||||||
|
struct FingerprintAssetsTests {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let fingerprint = FingerprintAssets()
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `fingerprints the files under a directory`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }",
|
||||||
|
"robots.txt": "User-agent: *"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = try #require(fingerprint(directory.path))
|
||||||
|
|
||||||
|
#expect(token.count == 16)
|
||||||
|
#expect(token.allSatisfy { $0.isHexDigit })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `agrees across directories with identical contents`() throws {
|
||||||
|
let files = [
|
||||||
|
"css/site.css": "body { margin: 0; }",
|
||||||
|
"js/site.js": "console.log(1);"
|
||||||
|
]
|
||||||
|
let first = try makeDirectory(files: files)
|
||||||
|
let second = try makeDirectory(files: files)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(first)
|
||||||
|
removeDirectory(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(first.path) == fingerprint(second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file's contents change`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = fingerprint(directory.path)
|
||||||
|
|
||||||
|
try "body { margin: 1px; }".write(
|
||||||
|
to: directory.appendingPathComponent("css/site.css"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) != before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file is renamed`() throws {
|
||||||
|
let contents = "body { margin: 0; }"
|
||||||
|
let first = try makeDirectory(files: ["css/site.css": contents])
|
||||||
|
let second = try makeDirectory(files: ["css/main.css": contents])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(first)
|
||||||
|
removeDirectory(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(first.path) != fingerprint(second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file is added`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = fingerprint(directory.path)
|
||||||
|
|
||||||
|
try "console.log(1);".write(
|
||||||
|
to: directory.appendingPathComponent("site.js"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) != before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `returns nil for a directory without files`() throws {
|
||||||
|
let directory = try makeDirectory(files: [:])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `returns nil for a missing directory`() {
|
||||||
|
let missing = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
#expect(fingerprint(missing.path) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension FingerprintAssetsTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Creates a unique temporary directory holding the given files, keyed by relative path.
|
||||||
|
/// - Parameter files: the files to create, keyed by their path relative to the directory.
|
||||||
|
/// - Returns: the URL of the created directory.
|
||||||
|
func makeDirectory(
|
||||||
|
files: [String: String]
|
||||||
|
) throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
for (relativePath, contents) in files {
|
||||||
|
let file = directory.appendingPathComponent(relativePath)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: file.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try contents.write(
|
||||||
|
to: file,
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a temporary directory created by ``makeDirectory(files:)``.
|
||||||
|
/// - Parameter directory: the URL of the directory to remove.
|
||||||
|
func removeDirectory(
|
||||||
|
_ directory: URL
|
||||||
|
) {
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import HTTPTypes
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import NIOCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
|
||||||
|
struct LocalizationMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
private let app: Application = .init(router: {
|
||||||
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware(bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("language") { _, context in
|
||||||
|
context.language
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}())
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `negotiates a supported language from the header`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/language",
|
||||||
|
method: .get,
|
||||||
|
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||||
|
) { response in
|
||||||
|
#expect(String(buffer: response.body) == "de")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the default without a header`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/language",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(String(buffer: response.body) == "en")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the default for an unsupported language`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/language",
|
||||||
|
method: .get,
|
||||||
|
headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
|
||||||
|
) { response in
|
||||||
|
#expect(String(buffer: response.body) == "en")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import Foundation
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import NIOCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
|
||||||
|
struct NotFoundMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
private let app: Application = .init(router: {
|
||||||
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware(bundle: .module)
|
||||||
|
NotFoundMiddleware(bundle: .module) {
|
||||||
|
StubPage(locale: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("hello") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("boom") { _, _ -> String in
|
||||||
|
throw HTTPError(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}())
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the error page for an unmatched request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||||
|
#expect(response.headers[.contentLanguage] == "en")
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language")
|
||||||
|
#expect(body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the error page in the negotiated language`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get,
|
||||||
|
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.contentLanguage] == "de")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `passes a matched response through untouched`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.body == ByteBuffer(string: "Hello!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `rethrows a non-not-found error unchanged`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/boom",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .badRequest)
|
||||||
|
#expect(!body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders versioned asset URLs when given a version`() async throws {
|
||||||
|
try await app(
|
||||||
|
assetVersion: "0123456789abcdef"
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/stub.css?v=0123456789abcdef"))
|
||||||
|
#expect(body.contains("/js/stub.js?v=0123456789abcdef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders unversioned asset URLs by default`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"href="/css/stub.css""#))
|
||||||
|
#expect(!body.contains("?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the error page without revalidation headers`() async throws {
|
||||||
|
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||||
|
// invite revalidation with an entity tag or a cache policy.
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.eTag] == nil)
|
||||||
|
#expect(response.headers[.cacheControl] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the full error page to a conditional request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: "*"]
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension NotFoundMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose not-found middleware appends the given version token to the
|
||||||
|
/// error page's asset URLs.
|
||||||
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||||
|
/// - Returns: the configured application.
|
||||||
|
func app(
|
||||||
|
assetVersion: String?
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware(bundle: .module)
|
||||||
|
NotFoundMiddleware(bundle: .module) {
|
||||||
|
StubPage(
|
||||||
|
locale: $0,
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("RateLimitMiddleware middleware", .tags(.middleware))
|
||||||
|
struct RateLimitMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `admits requests within the limit`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 3)
|
||||||
|
).test(.router) { client in
|
||||||
|
for _ in 1 ... 3 {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `rejects a request over the limit with a retry-after header`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 2)
|
||||||
|
).test(.router) { client in
|
||||||
|
for _ in 1 ... 2 {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
|
||||||
|
let retryAfter = try #require(response.headers[.retryAfter])
|
||||||
|
|
||||||
|
#expect(try #require(Int(retryAfter)) >= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `admits requests again once the window resets`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(
|
||||||
|
limit: 1,
|
||||||
|
window: .milliseconds(50)
|
||||||
|
)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `separates clients by their forwarded address when trusted`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(
|
||||||
|
limit: 1,
|
||||||
|
trustForwardedFor: true
|
||||||
|
)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.8"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
// The first entry names the client; the appended proxy hop must not change its key.
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `ignores the forwarded address when not trusted`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 1)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
// Without trust (and without a connection address in router-only testing), every client
|
||||||
|
// shares one bucket, so a rotated header must not mint a fresh budget.
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.8"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension RateLimitMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose router applies the rate-limit middleware ahead of a single
|
||||||
|
/// `/hello` route returning a plain body.
|
||||||
|
func app(
|
||||||
|
configuration: RateLimitMiddleware<BasicRequestContext>.Configuration
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
RateLimitMiddleware(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("hello") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+32
-10
@@ -2,7 +2,7 @@ import Hummingbird
|
|||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import Infrastructure
|
||||||
|
|
||||||
@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware))
|
@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware))
|
||||||
struct SecurityHeadersMiddlewareTests {
|
struct SecurityHeadersMiddlewareTests {
|
||||||
@@ -17,11 +17,11 @@ struct SecurityHeadersMiddlewareTests {
|
|||||||
method: .get
|
method: .get
|
||||||
) { response in
|
) { response in
|
||||||
#expect(response.status == .ok)
|
#expect(response.status == .ok)
|
||||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
|
||||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||||
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
#expect(response.headers[.frameOptions] == .Security.frameOptions)
|
||||||
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
|
||||||
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,24 @@ struct SecurityHeadersMiddlewareTests {
|
|||||||
uri: "/weak",
|
uri: "/weak",
|
||||||
method: .get
|
method: .get
|
||||||
) { response in
|
) { response in
|
||||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `applies the security headers to an error response`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/throws",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .badRequest)
|
||||||
|
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
|
||||||
|
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||||
|
#expect(response.headers[.frameOptions] == .Security.frameOptions)
|
||||||
|
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
|
||||||
|
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,9 +121,10 @@ private extension SecurityHeadersMiddlewareTests {
|
|||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
/// Builds an application whose router applies the security-headers middleware ahead of two
|
/// Builds an application whose router applies the security-headers middleware ahead of three
|
||||||
/// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a
|
/// 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.
|
/// 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(
|
func app(
|
||||||
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
|
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
|
||||||
) -> some ApplicationProtocol {
|
) -> some ApplicationProtocol {
|
||||||
@@ -128,6 +146,10 @@ private extension SecurityHeadersMiddlewareTests {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get("throws") { _, _ -> Response in
|
||||||
|
throw HTTPError(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
return Application(router: router)
|
return Application(router: router)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import HTTPTypes
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("VaryMiddleware middleware", .tags(.middleware))
|
||||||
|
struct VaryMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `adds the default field to a response without a vary header`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/plain",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `appends to an existing vary header`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/localized",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language, Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `does not duplicate a name already present`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/encoded",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `matches an existing name regardless of its casing`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/lowercased",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "accept-encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `normalizes the whitespace of an existing list`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/spaced",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `appends every configured field`() async throws {
|
||||||
|
try await app(
|
||||||
|
fields: [.acceptEncoding, .acceptLanguage]
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/plain",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding, Accept-Language")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension VaryMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose router applies the vary middleware ahead of routes whose
|
||||||
|
/// responses carry different `Vary` starting points: `/plain` none, `/localized` an
|
||||||
|
/// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase
|
||||||
|
/// `accept-encoding`, and `/spaced` a list with irregular whitespace.
|
||||||
|
func app(
|
||||||
|
fields: [HTTPField.Name] = [.acceptEncoding]
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
VaryMiddleware(fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("plain") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, vary) in [
|
||||||
|
("localized", "Accept-Language"),
|
||||||
|
("encoded", "Accept-Encoding"),
|
||||||
|
("lowercased", "accept-encoding"),
|
||||||
|
("spaced", "Accept-Language , User-Agent"),
|
||||||
|
] {
|
||||||
|
router.get(RouterPath(path)) { _, _ -> Response in
|
||||||
|
var response = Response(status: .ok)
|
||||||
|
|
||||||
|
response.headers[.vary] = vary
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("Asset protocol")
|
||||||
|
struct AssetTests {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let image = StubAsset(
|
||||||
|
fileExtensions: [.png],
|
||||||
|
fileName: "icon"
|
||||||
|
)
|
||||||
|
private let shared = StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: Method tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `relative path nests the file inside its extension's sub-directory`() {
|
||||||
|
#expect(shared.relativePath(for: .css) == "css/shared.css")
|
||||||
|
#expect(shared.relativePath(for: .js) == "js/shared.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `relative path keeps the file at the root without a sub-directory`() {
|
||||||
|
#expect(image.relativePath(for: .png) == "icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `url path prefixes the relative path with a slash`() {
|
||||||
|
#expect(shared.urlPath(for: .css) == "/css/shared.css")
|
||||||
|
#expect(image.urlPath(for: .png) == "/icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `url path appends a version token as a query parameter`() {
|
||||||
|
#expect(shared.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: "0123456789abcdef"
|
||||||
|
) == "/css/shared.css?v=0123456789abcdef")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [nil, ""] as [String?])
|
||||||
|
func `url path without a version`(
|
||||||
|
version: String?
|
||||||
|
) {
|
||||||
|
#expect(shared.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: version
|
||||||
|
) == "/css/shared.css")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"Resources/Static"
|
||||||
|
])
|
||||||
|
func `path relative to`(
|
||||||
|
_ basePath: String
|
||||||
|
) {
|
||||||
|
for fileExtension in shared.fileExtensions {
|
||||||
|
let pathRelativeToBasePath = shared.path(
|
||||||
|
relativeTo: basePath,
|
||||||
|
for: fileExtension
|
||||||
|
)
|
||||||
|
let relativePath = shared.relativePath(for: fileExtension)
|
||||||
|
|
||||||
|
if basePath.isEmpty {
|
||||||
|
#expect(pathRelativeToBasePath == relativePath)
|
||||||
|
} else {
|
||||||
|
#expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite("Page protocol", .tags(.page))
|
||||||
|
struct PageTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `assembles the document around the page's parts`() {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
#expect(html.contains("<title>Stub Page</title>"))
|
||||||
|
#expect(html.contains(#"lang="en""#))
|
||||||
|
#expect(html.contains(#"name="viewport""#))
|
||||||
|
#expect(html.contains(#"<meta name="stub" content="marker">"#))
|
||||||
|
#expect(html.contains(#"<link rel="stylesheet" href="/css/stub.css">"#))
|
||||||
|
#expect(html.contains(#"<script src="/js/stub.js"></script>"#))
|
||||||
|
#expect(html.contains("Stub content"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `places the metadata between the viewport and the stylesheets`() throws {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
let viewport = try #require(html.range(of: #"name="viewport""#))
|
||||||
|
let metadata = try #require(html.range(of: #"name="stub""#))
|
||||||
|
let stylesheet = try #require(html.range(of: "/css/stub.css"))
|
||||||
|
|
||||||
|
#expect(viewport.lowerBound < metadata.lowerBound)
|
||||||
|
#expect(metadata.lowerBound < stylesheet.lowerBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the scripts after the content`() throws {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
let content = try #require(html.range(of: "Stub content"))
|
||||||
|
let script = try #require(html.range(of: "/js/stub.js"))
|
||||||
|
|
||||||
|
#expect(content.lowerBound < script.lowerBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `appends the version token to the asset URLs`() {
|
||||||
|
let html = StubPage(assetVersion: "0123456789abcdef").render()
|
||||||
|
|
||||||
|
#expect(html.contains("/css/stub.css?v=0123456789abcdef"))
|
||||||
|
#expect(html.contains("/js/stub.js?v=0123456789abcdef"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `derives the document language from the locale`() {
|
||||||
|
let html = StubPage(locale: .init(identifier: "de-DE")).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"lang="de""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
/// Tests exercising a middleware of the Infrastructure package.
|
||||||
|
@Tag static var middleware: Tag
|
||||||
|
/// Tests exercising the page scaffolding of the Infrastructure package.
|
||||||
|
@Tag static var page: Tag
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
|
/// A ``Page`` with fixed content, metadata, and stub assets.
|
||||||
|
struct StubPage: Page {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
|
let assetVersion: String?
|
||||||
|
|
||||||
|
/// The locale the page content is localized to.
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a stub page.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - locale: the locale the page content is localized to. Defaults to `en`.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||||
|
/// default) to leave them unversioned.
|
||||||
|
init(
|
||||||
|
locale: Locale = .init(identifier: "en"),
|
||||||
|
assetVersion: String? = nil
|
||||||
|
) {
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.locale = locale
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
p { "Stub content" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var lang: String {
|
||||||
|
locale.language.languageCode?.identifier ?? "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("stub"),
|
||||||
|
.content("marker")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "stub"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "stub"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Stub Page"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -55,9 +55,9 @@ RUN swift package --package-path ./Services/Website resolve
|
|||||||
|
|
||||||
# Copy only the Swift inputs needed for a release build. Static assets are built
|
# 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.
|
# 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/Localization/Sources ./Packages/Localization/Sources
|
||||||
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
||||||
COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources
|
|
||||||
COPY ./Services/Website/Sources ./Services/Website/Sources
|
COPY ./Services/Website/Sources ./Services/Website/Sources
|
||||||
COPY ./Services/Website/Tests ./Services/Website/Tests
|
COPY ./Services/Website/Tests ./Services/Website/Tests
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "WebsiteTests",
|
name: "WebsiteTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Infrastructure"),
|
||||||
.byName(name: "Website"),
|
.byName(name: "Website"),
|
||||||
.product(
|
.product(
|
||||||
name: "HummingbirdTesting",
|
name: "HummingbirdTesting",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Two SwiftPM targets:
|
|||||||
|
|
||||||
The `Website` executable depends on three local packages:
|
The `Website` executable depends on three local packages:
|
||||||
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
||||||
- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively.
|
- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
|
||||||
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
||||||
|
|
||||||
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
||||||
@@ -65,7 +65,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
|||||||
### Static file caching
|
### Static file caching
|
||||||
| Config key | Environment variable | Default | Description |
|
| 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=<token>`), 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.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). |
|
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). |
|
||||||
|
|
||||||
@@ -106,6 +107,13 @@ See [Persistence](#persistence-1) below for the workflow.
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
||||||
|
|
||||||
|
### Rate limiting
|
||||||
|
| Config key | Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||||
|
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
|
||||||
|
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. |
|
||||||
|
|
||||||
### Security headers
|
### Security headers
|
||||||
| Config key | Environment variable | Default |
|
| Config key | Environment variable | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func application(
|
|||||||
logger: logger
|
logger: logger
|
||||||
)
|
)
|
||||||
let fluent = persistence()
|
let fluent = persistence()
|
||||||
|
|
||||||
|
let fingerprintAssets = FingerprintAssets(logger: logger)
|
||||||
let prepareDB = PrepareDB()
|
let prepareDB = PrepareDB()
|
||||||
|
|
||||||
await prepareDB(for: fluent)
|
await prepareDB(for: fluent)
|
||||||
@@ -33,8 +35,10 @@ func application(
|
|||||||
var app = Application(
|
var app = Application(
|
||||||
router: router(
|
router: router(
|
||||||
staticFilesPath: reader.staticFilesPath,
|
staticFilesPath: reader.staticFilesPath,
|
||||||
|
assetVersion: fingerprintAssets(reader.staticFilesPath),
|
||||||
cacheControl: reader.cacheControl,
|
cacheControl: reader.cacheControl,
|
||||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||||
|
rateLimit: reader.rateLimit,
|
||||||
securityHeaders: reader.securityHeaders,
|
securityHeaders: reader.securityHeaders,
|
||||||
logLevel: reader.logLevel,
|
logLevel: reader.logLevel,
|
||||||
probe: Probe(fluent: fluent)
|
probe: Probe(fluent: fluent)
|
||||||
@@ -117,25 +121,31 @@ private func logger(
|
|||||||
/// Builds the application's router.
|
/// Builds the application's router.
|
||||||
///
|
///
|
||||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
|
/// 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,
|
/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
|
||||||
/// the localization middleware that negotiates the request's language from its `Accept-Language` header, the not-found middleware that serves the
|
/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's
|
||||||
/// error page, and the static file middleware that serves the contents of `staticFilesPath` (tagging responses with the given `cacheControl`
|
/// language from its `Accept-Language` header, the not-found middleware that serves the error page, and the static file middleware that serves the
|
||||||
/// directives), then adds the `RootController` routes that render the landing page and the `HealthController` routes that serve the health check.
|
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that
|
||||||
|
/// render the landing page, the `SubscriptionController` routes that register newsletter subscriptions, and the `HealthController` routes
|
||||||
|
/// that serve the health check.
|
||||||
///
|
///
|
||||||
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed
|
/// 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.
|
/// responses, the rendered error page, and the served static files.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
/// - 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.
|
/// - cacheControl: the cache-control directives applied to the served static files.
|
||||||
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
|
/// - 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.
|
/// - securityHeaders: the security headers applied to every response.
|
||||||
/// - logLevel: the level the request-logging middleware logs at.
|
/// - logLevel: the level the request-logging middleware logs at.
|
||||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||||
/// - Returns: the configured router.
|
/// - Returns: the configured router.
|
||||||
private func router(
|
private func router(
|
||||||
staticFilesPath: String,
|
staticFilesPath: String,
|
||||||
|
assetVersion: String?,
|
||||||
cacheControl: CacheControl,
|
cacheControl: CacheControl,
|
||||||
compressionMinResponseSize: Int,
|
compressionMinResponseSize: Int,
|
||||||
|
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||||
logLevel: Logger.Level,
|
logLevel: Logger.Level,
|
||||||
probe: Probe
|
probe: Probe
|
||||||
@@ -152,11 +162,14 @@ private func router(
|
|||||||
SecurityHeadersMiddleware(
|
SecurityHeadersMiddleware(
|
||||||
configuration: securityHeaders
|
configuration: securityHeaders
|
||||||
)
|
)
|
||||||
|
VaryMiddleware()
|
||||||
ResponseCompressionMiddleware(
|
ResponseCompressionMiddleware(
|
||||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||||
)
|
)
|
||||||
LocalizationMiddleware()
|
LocalizationMiddleware()
|
||||||
NotFoundMiddleware()
|
NotFoundMiddleware(
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
FileMiddleware(
|
FileMiddleware(
|
||||||
staticFilesPath,
|
staticFilesPath,
|
||||||
cacheControl: cacheControl
|
cacheControl: cacheControl
|
||||||
@@ -164,7 +177,9 @@ private func router(
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.addController {
|
router.addController {
|
||||||
RootController<AppRequestContext>()
|
RootController<AppRequestContext>(
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
HealthController<AppRequestContext>(
|
HealthController<AppRequestContext>(
|
||||||
probe: probe
|
probe: probe
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Configuration
|
import Configuration
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
import Infrastructure
|
||||||
import Logging
|
import Logging
|
||||||
import Persistence
|
import Persistence
|
||||||
import WebsiteLibrary
|
import WebsiteLibrary
|
||||||
@@ -15,9 +16,16 @@ package extension ConfigReader {
|
|||||||
|
|
||||||
/// The `Cache-Control` policy applied to static files, grouped by media type.
|
/// 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,
|
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
|
||||||
/// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
|
/// `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 {
|
var cacheControl: CacheControl {
|
||||||
|
let maxAgeAsset = int(
|
||||||
|
forKey: .Cache.maxAgeAsset,
|
||||||
|
default: .Cache.maxAgeAsset
|
||||||
|
)
|
||||||
let maxAgeDefault = int(
|
let maxAgeDefault = int(
|
||||||
forKey: .Cache.maxAgeDefault,
|
forKey: .Cache.maxAgeDefault,
|
||||||
default: .Cache.maxAgeDefault
|
default: .Cache.maxAgeDefault
|
||||||
@@ -32,6 +40,9 @@ package extension ConfigReader {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return .init([
|
return .init([
|
||||||
|
(.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||||
|
(.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||||
|
(.font, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||||
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
|
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
|
||||||
(.image, [.public, .maxAge(maxAgeImage)]),
|
(.image, [.public, .maxAge(maxAgeImage)]),
|
||||||
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
|
(.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<AppRequestContext>.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.
|
/// 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`
|
/// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import Infrastructure
|
||||||
|
|
||||||
/// A static file shipped with the website service.
|
/// A static file shipped with the website service.
|
||||||
///
|
///
|
||||||
/// Each case identifies a file name stored under the static files root (the `Resources/Static`
|
/// 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
|
/// 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.
|
/// with more than one extension (see ``fileExtensions``), each resolving to its own file.
|
||||||
enum StaticFile: CaseIterable, Sendable {
|
enum StaticFile: Asset, CaseIterable {
|
||||||
/// The `apple-touch-icon.png` icon.
|
/// The `apple-touch-icon.png` icon.
|
||||||
case appleTouchIcon
|
case appleTouchIcon
|
||||||
/// The `css/error.css` stylesheet and `js/error.js` script for the not-found page.
|
/// The `css/error.css` stylesheet and `js/error.js` script for the not-found page.
|
||||||
@@ -28,30 +30,6 @@ enum StaticFile: CaseIterable, Sendable {
|
|||||||
case sitemap
|
case sitemap
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enumerations
|
|
||||||
|
|
||||||
extension StaticFile {
|
|
||||||
/// A file extension used by a ``StaticFile``.
|
|
||||||
enum Extension: String, Sendable {
|
|
||||||
/// A Cascading Style Sheets file.
|
|
||||||
case css
|
|
||||||
/// A JavaScript file.
|
|
||||||
case js
|
|
||||||
/// A Portable Network Graphics image.
|
|
||||||
case png
|
|
||||||
/// A Windows icon image.
|
|
||||||
case ico
|
|
||||||
/// A Scalable Vector Graphics image.
|
|
||||||
case svg
|
|
||||||
/// A plain text file.
|
|
||||||
case txt
|
|
||||||
/// A web application manifest file.
|
|
||||||
case webmanifest
|
|
||||||
/// An Extensible Markup Language file.
|
|
||||||
case xml
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Extensions
|
// MARK: - Extensions
|
||||||
|
|
||||||
extension StaticFile {
|
extension StaticFile {
|
||||||
@@ -59,7 +37,7 @@ extension StaticFile {
|
|||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The file extensions the file is available with.
|
/// The file extensions the file is available with.
|
||||||
var fileExtensions: [Extension] {
|
var fileExtensions: [AssetExtension] {
|
||||||
switch self {
|
switch self {
|
||||||
case .appleTouchIcon,
|
case .appleTouchIcon,
|
||||||
.icon192,
|
.icon192,
|
||||||
@@ -92,79 +70,4 @@ extension StaticFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Methods
|
|
||||||
|
|
||||||
/// Resolves the file's path against the given base directory.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - basePath: the directory the static files are served from.
|
|
||||||
/// - fileExtension: the extension of the file to resolve.
|
|
||||||
/// - Returns: the path to the file, relative to the `basePath` path.
|
|
||||||
func path(
|
|
||||||
relativeTo basePath: String,
|
|
||||||
for fileExtension: Extension
|
|
||||||
) -> String {
|
|
||||||
let relativePath = relativePath(for: fileExtension)
|
|
||||||
|
|
||||||
guard !basePath.isEmpty else {
|
|
||||||
return relativePath
|
|
||||||
}
|
|
||||||
|
|
||||||
return "\(basePath)/\(relativePath)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves the file's path relative to the static files root (e.g. `"css/shared.css"`).
|
|
||||||
///
|
|
||||||
/// This also matches the URL path the file is served at by `FileMiddleware`.
|
|
||||||
///
|
|
||||||
/// - Parameter fileExtension: the extension of the file to resolve.
|
|
||||||
/// - Returns: the path to the file, relative to the static files root.
|
|
||||||
func relativePath(
|
|
||||||
for fileExtension: Extension
|
|
||||||
) -> String {
|
|
||||||
let file = "\(fileName).\(fileExtension.rawValue)"
|
|
||||||
|
|
||||||
return fileExtension.subdirectory
|
|
||||||
.map { "\($0)/\(file)" } ?? file
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`).
|
|
||||||
///
|
|
||||||
/// - Parameter fileExtension: the extension of the file to resolve.
|
|
||||||
/// - Returns: the path to use in `href` and `src` attributes.
|
|
||||||
func urlPath(
|
|
||||||
for fileExtension: Extension
|
|
||||||
) -> String {
|
|
||||||
"/\(relativePath(for: fileExtension))"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StaticFile.Extension {
|
|
||||||
|
|
||||||
// MARK: Computed
|
|
||||||
|
|
||||||
/// The file's content type.
|
|
||||||
var contentType: String {
|
|
||||||
switch self {
|
|
||||||
case .css: "text/css"
|
|
||||||
case .js: "text/javascript"
|
|
||||||
case .png: "image/png"
|
|
||||||
case .ico: "image/vnd.microsoft.icon"
|
|
||||||
case .svg: "image/svg+xml"
|
|
||||||
case .txt: "text/plain"
|
|
||||||
case .webmanifest: "application/manifest+json"
|
|
||||||
case .xml: "application/xml"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sub-directory within the static root that holds files with this extension, if any.
|
|
||||||
var subdirectory: String? {
|
|
||||||
switch self {
|
|
||||||
case .css: "css"
|
|
||||||
case .js: "js"
|
|
||||||
default: nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Localization
|
||||||
|
|
||||||
|
/// The site-wide defaults shared by every page of the website.
|
||||||
|
extension Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The document language, derived from the page's locale and falling back to the default language.
|
||||||
|
var lang: String {
|
||||||
|
locale.language.languageCode?.identifier
|
||||||
|
?? LanguageList(bundle: .module).default
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon, manifest, and theme colour metadata shared by every page of the website.
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
link(
|
||||||
|
.rel(.icon),
|
||||||
|
.href(StaticFile.favicon.urlPath(
|
||||||
|
for: .ico,
|
||||||
|
version: assetVersion
|
||||||
|
)),
|
||||||
|
.custom(
|
||||||
|
name: "sizes",
|
||||||
|
value: "any"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel(.icon),
|
||||||
|
.href(StaticFile.icon.urlPath(
|
||||||
|
for: .svg,
|
||||||
|
version: assetVersion
|
||||||
|
)),
|
||||||
|
.custom(
|
||||||
|
name: "type",
|
||||||
|
value: "image/svg+xml"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel("apple-touch-icon"),
|
||||||
|
.href(StaticFile.appleTouchIcon.urlPath(
|
||||||
|
for: .png,
|
||||||
|
version: assetVersion
|
||||||
|
))
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel("manifest"),
|
||||||
|
.href(StaticFile.site.urlPath(
|
||||||
|
for: .webmanifest,
|
||||||
|
version: assetVersion
|
||||||
|
))
|
||||||
|
)
|
||||||
|
meta(
|
||||||
|
.name("theme-color"),
|
||||||
|
.content("#fafafa")
|
||||||
|
)
|
||||||
|
meta(
|
||||||
|
.name("theme-color"),
|
||||||
|
.content("#0c0710"),
|
||||||
|
.custom(
|
||||||
|
name: "media",
|
||||||
|
value: "(prefers-color-scheme: dark)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
import Localization
|
import Localization
|
||||||
|
|
||||||
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
|
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
|
||||||
@@ -7,6 +8,9 @@ struct ErrorPage {
|
|||||||
|
|
||||||
// MARK: Properties
|
// 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.
|
/// The locale the page content is localized to.
|
||||||
let locale: Locale
|
let locale: Locale
|
||||||
|
|
||||||
@@ -16,10 +20,15 @@ struct ErrorPage {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a not-found page localized to the given locale.
|
/// Creates a not-found page localized to the given locale.
|
||||||
/// - Parameter locale: the locale the page content is localized to.
|
/// - Parameters:
|
||||||
|
/// - locale: the locale the page content is localized to.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||||
|
/// default) to leave them unversioned.
|
||||||
init(
|
init(
|
||||||
locale: Locale
|
locale: Locale,
|
||||||
|
assetVersion: String? = nil
|
||||||
) {
|
) {
|
||||||
|
self.assetVersion = assetVersion
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.localize = .init(bundle: .module)
|
self.localize = .init(bundle: .module)
|
||||||
}
|
}
|
||||||
@@ -40,12 +49,12 @@ extension ErrorPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var scripts: [StaticFile] {
|
var scripts: [any Asset] {
|
||||||
[.error, .shared]
|
[StaticFile.error, StaticFile.shared]
|
||||||
}
|
}
|
||||||
|
|
||||||
var stylesheets: [StaticFile] {
|
var stylesheets: [any Asset] {
|
||||||
[.shared, .error]
|
[StaticFile.shared, StaticFile.error]
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
import Localization
|
import Localization
|
||||||
|
|
||||||
/// The website's landing page, with its text localized to a given locale.
|
/// The website's landing page, with its text localized to a given locale.
|
||||||
@@ -7,6 +8,9 @@ struct IndexPage {
|
|||||||
|
|
||||||
// MARK: Properties
|
// 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.
|
/// The locale the page content is localized to.
|
||||||
let locale: Locale
|
let locale: Locale
|
||||||
|
|
||||||
@@ -16,10 +20,15 @@ struct IndexPage {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a landing page localized to the given locale.
|
/// 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(
|
init(
|
||||||
locale: Locale
|
locale: Locale,
|
||||||
|
assetVersion: String? = nil
|
||||||
) {
|
) {
|
||||||
|
self.assetVersion = assetVersion
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.localize = .init(bundle: .module)
|
self.localize = .init(bundle: .module)
|
||||||
}
|
}
|
||||||
@@ -38,12 +47,12 @@ extension IndexPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var scripts: [StaticFile] {
|
var scripts: [any Asset] {
|
||||||
[.index, .shared]
|
[StaticFile.index, StaticFile.shared]
|
||||||
}
|
}
|
||||||
|
|
||||||
var stylesheets: [StaticFile] {
|
var stylesheets: [any Asset] {
|
||||||
[.shared, .index]
|
[StaticFile.shared, StaticFile.index]
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import Elementary
|
|
||||||
import Foundation
|
|
||||||
import Localization
|
|
||||||
|
|
||||||
/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content.
|
|
||||||
///
|
|
||||||
/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the
|
|
||||||
/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the
|
|
||||||
/// document language derived from the locale.
|
|
||||||
protocol Page: HTMLDocument, Sendable {
|
|
||||||
|
|
||||||
// MARK: Associated types
|
|
||||||
|
|
||||||
/// The type of the page's markup.
|
|
||||||
associatedtype Content: HTML
|
|
||||||
|
|
||||||
// MARK: Properties
|
|
||||||
|
|
||||||
/// The page's markup, rendered before the ``scripts``.
|
|
||||||
@HTMLBuilder
|
|
||||||
var content: Content { get }
|
|
||||||
|
|
||||||
/// The locale the page content is localized to.
|
|
||||||
var locale: Locale { get }
|
|
||||||
|
|
||||||
/// The scripts loaded at the end of the document body, in order.
|
|
||||||
var scripts: [StaticFile] { get }
|
|
||||||
|
|
||||||
/// The stylesheets linked in the document head, in order.
|
|
||||||
var stylesheets: [StaticFile] { get }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Implementations
|
|
||||||
|
|
||||||
extension Page {
|
|
||||||
|
|
||||||
// MARK: Computed
|
|
||||||
|
|
||||||
/// The page ``content`` followed by its ``scripts``.
|
|
||||||
@HTMLBuilder
|
|
||||||
var body: some HTML {
|
|
||||||
content
|
|
||||||
for file in scripts {
|
|
||||||
script(.src(file.urlPath(for: .js))) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The metadata, ``stylesheets``, icon, and manifest links placed in the document head.
|
|
||||||
///
|
|
||||||
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already
|
|
||||||
/// emits `<meta charset="UTF-8">` before this markup, and HTML5 allows only one.
|
|
||||||
@HTMLBuilder
|
|
||||||
var head: some HTML {
|
|
||||||
meta(
|
|
||||||
.name(.viewport),
|
|
||||||
.content("width=device-width, initial-scale=1")
|
|
||||||
)
|
|
||||||
for file in stylesheets {
|
|
||||||
link(
|
|
||||||
.rel(.stylesheet),
|
|
||||||
.href(file.urlPath(for: .css))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
link(
|
|
||||||
.rel(.icon),
|
|
||||||
.href(StaticFile.favicon.urlPath(for: .ico)),
|
|
||||||
.custom(
|
|
||||||
name: "sizes",
|
|
||||||
value: "any"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
link(
|
|
||||||
.rel(.icon),
|
|
||||||
.href(StaticFile.icon.urlPath(for: .svg)),
|
|
||||||
.custom(
|
|
||||||
name: "type",
|
|
||||||
value: "image/svg+xml"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
link(
|
|
||||||
.rel("apple-touch-icon"),
|
|
||||||
.href(StaticFile.appleTouchIcon.urlPath(for: .png))
|
|
||||||
)
|
|
||||||
link(
|
|
||||||
.rel("manifest"),
|
|
||||||
.href(StaticFile.site.urlPath(for: .webmanifest))
|
|
||||||
)
|
|
||||||
meta(
|
|
||||||
.name("theme-color"),
|
|
||||||
.content("#fafafa")
|
|
||||||
)
|
|
||||||
meta(
|
|
||||||
.name("theme-color"),
|
|
||||||
.content("#0c0710"),
|
|
||||||
.custom(
|
|
||||||
name: "media",
|
|
||||||
value: "(prefers-color-scheme: dark)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The document language, derived from the page's locale and falling back to the default language.
|
|
||||||
var lang: String {
|
|
||||||
locale.language.languageCode?.identifier
|
|
||||||
?? LanguageList(bundle: .module).default
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+8
-18
@@ -1,26 +1,13 @@
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
import Infrastructure
|
||||||
/// A request context that carries the language negotiated for the request.
|
import NIOCore
|
||||||
///
|
|
||||||
/// ``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
|
|
||||||
|
|
||||||
/// The website's request context.
|
/// The website's request context.
|
||||||
///
|
///
|
||||||
/// Extends the core request storage with the negotiated language, defaulting to the default
|
/// Extends the core request storage with the negotiated language, defaulting to the default
|
||||||
/// supported language until ``LocalizationMiddleware`` resolves it from the request.
|
/// supported language until ``LocalizationMiddleware`` resolves it from the request, and with
|
||||||
public struct WebsiteRequestContext: LocalizedRequestContext {
|
/// the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
|
||||||
|
public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
@@ -28,6 +15,8 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
|
|||||||
public var coreContext: CoreRequestContextStorage
|
public var coreContext: CoreRequestContextStorage
|
||||||
/// The language identifier negotiated for the request.
|
/// The language identifier negotiated for the request.
|
||||||
public var language: String
|
public var language: String
|
||||||
|
/// The address of the connected client, captured from the source channel.
|
||||||
|
public let remoteAddress: SocketAddress?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
@@ -38,6 +27,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
|
|||||||
) {
|
) {
|
||||||
self.coreContext = .init(source: source)
|
self.coreContext = .init(source: source)
|
||||||
self.language = .empty
|
self.language = .empty
|
||||||
|
self.remoteAddress = source.channel.remoteAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import Infrastructure
|
import Infrastructure
|
||||||
|
|
||||||
@@ -24,9 +25,16 @@ public struct RootController<Context: LocalizedRequestContext> {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a root controller.
|
/// Creates a root controller.
|
||||||
public init() {
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil`
|
||||||
self.responses = .init {
|
/// (the default) to leave them unversioned.
|
||||||
IndexPage(locale: $0)
|
public init(
|
||||||
|
assetVersion: String? = nil
|
||||||
|
) {
|
||||||
|
self.responses = .init(bundle: .module) {
|
||||||
|
IndexPage(
|
||||||
|
locale: $0,
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +78,10 @@ private extension RootController {
|
|||||||
request: Request,
|
request: Request,
|
||||||
context: Context
|
context: Context
|
||||||
) -> Response {
|
) -> Response {
|
||||||
responses.response(for: context.language)
|
responses.response(
|
||||||
|
for: context.language,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,9 @@ import Configuration
|
|||||||
extension AbsoluteConfigKey {
|
extension AbsoluteConfigKey {
|
||||||
/// A namespace for the static files cache configuration keys, as absolute keys.
|
/// A namespace for the static files cache configuration keys, as absolute keys.
|
||||||
public enum Cache {
|
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)
|
public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText)
|
||||||
/// The absolute configuration key for the max-age, in seconds, applied to image static files.
|
/// The absolute configuration key for the max-age, in seconds, applied to image static files.
|
||||||
public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage)
|
public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage)
|
||||||
@@ -50,6 +52,15 @@ extension AbsoluteConfigKey {
|
|||||||
/// The absolute configuration key for the minimum log level.
|
/// The absolute configuration key for the minimum log level.
|
||||||
public static let level: AbsoluteConfigKey = .init(.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.
|
/// A namespace for the path configuration keys, as absolute keys.
|
||||||
public enum Path {
|
public enum Path {
|
||||||
/// The absolute configuration key for the directory the static files are served from.
|
/// The absolute configuration key for the directory the static files are served from.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import Configuration
|
|||||||
extension ConfigKey {
|
extension ConfigKey {
|
||||||
/// A namespace for the static files cache configuration keys.
|
/// A namespace for the static files cache configuration keys.
|
||||||
public enum Cache {
|
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"
|
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).
|
/// 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"
|
public static let maxAgeImage: ConfigKey = "cache.maxAge.image"
|
||||||
@@ -50,6 +52,15 @@ extension ConfigKey {
|
|||||||
/// The configuration key for the minimum log level.
|
/// The configuration key for the minimum log level.
|
||||||
public static let level: ConfigKey = "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.
|
/// A namespace for the path configuration keys.
|
||||||
public enum Path {
|
public enum Path {
|
||||||
/// The configuration key for the directory the static files are served from.
|
/// The configuration key for the directory the static files are served from.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
extension Int {
|
extension Int {
|
||||||
/// A namespace for the cache's default configuration values.
|
/// A namespace for the cache's default configuration values.
|
||||||
public enum Cache {
|
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
|
public static let maxAgeText = 3_600
|
||||||
/// The default max-age, in seconds, applied to image static files (1 week).
|
/// The default max-age, in seconds, applied to image static files (1 week).
|
||||||
public static let maxAgeImage = 604_800
|
public static let maxAgeImage = 604_800
|
||||||
|
|||||||
+13
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
|
public extension NotFoundMiddleware {
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
|
||||||
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
init(
|
||||||
|
assetVersion: String? = nil
|
||||||
|
) {
|
||||||
|
self.init(bundle: .module) {
|
||||||
|
ErrorPage(
|
||||||
|
locale: $0,
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,27 +23,6 @@ extension String {
|
|||||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||||
public static let staticResources = "Resources/Static"
|
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.
|
/// A namespace for the server string constants.
|
||||||
public enum Server {
|
public enum Server {
|
||||||
/// The website server's name.
|
/// The website server's name.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Configuration
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
|
import Infrastructure
|
||||||
import NIOCore
|
import NIOCore
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -13,10 +14,10 @@ struct AppTests {
|
|||||||
|
|
||||||
// MARK: Constants
|
// 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,
|
.css,
|
||||||
.js,
|
.js
|
||||||
.txt
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Absolute path to the package's "Resources/Static" folder, derived from this
|
// Absolute path to the package's "Resources/Static" folder, derived from this
|
||||||
@@ -48,6 +49,22 @@ struct AppTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `landing page to answer a head request`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .head
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||||
|
#expect(response.body.readableBytes == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `health check to be served at the health path`() async throws {
|
func `health check to be served at the health path`() async throws {
|
||||||
try await app(
|
try await app(
|
||||||
@@ -106,7 +123,9 @@ struct AppTests {
|
|||||||
#expect(cacheControl.contains("public") == true)
|
#expect(cacheControl.contains("public") == true)
|
||||||
#expect(cacheControl.contains("max-age=") == true)
|
#expect(cacheControl.contains("max-age=") == true)
|
||||||
|
|
||||||
if textExtensions.contains(fileExtension) {
|
if immutableExtensions.contains(fileExtension) {
|
||||||
|
#expect(cacheControl.contains("immutable") == true)
|
||||||
|
} else if fileExtension == .txt {
|
||||||
#expect(cacheControl.contains("must-revalidate") == true)
|
#expect(cacheControl.contains("must-revalidate") == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +133,81 @@ struct AppTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `versioned asset URL to be served`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/css/shared.css?v=0123456789abcdef",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.contentType] == "text/css")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `landing page to reference fingerprinted assets`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/shared.css?v="))
|
||||||
|
#expect(body.contains("/js/shared.js?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `landing page to revalidate with an entity tag`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
let eTag = try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||||
|
|
||||||
|
return try #require(response.headers[.eTag])
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: eTag]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notModified)
|
||||||
|
#expect(response.body.readableBytes == 0)
|
||||||
|
#expect(response.headers[.eTag] == eTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `responses to vary on language and encoding`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let vary = try #require(response.headers[.vary])
|
||||||
|
|
||||||
|
#expect(vary.contains("Accept-Language"))
|
||||||
|
#expect(vary.contains("Accept-Encoding"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `response to be compressed when the client supports it`() async throws {
|
func `response to be compressed when the client supports it`() async throws {
|
||||||
try await app(
|
try await app(
|
||||||
@@ -163,6 +257,81 @@ struct AppTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `error page to reference fingerprinted assets`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/error.css?v="))
|
||||||
|
#expect(body.contains("/js/shared.js?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `error page to be served without revalidation headers`() async throws {
|
||||||
|
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||||
|
// invite revalidation with an entity tag or a cache policy.
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.eTag] == nil)
|
||||||
|
#expect(response.headers[.cacheControl] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `error page to vary on language and encoding`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let vary = try #require(response.headers[.vary])
|
||||||
|
|
||||||
|
#expect(vary.contains("Accept-Language"))
|
||||||
|
#expect(vary.contains("Accept-Encoding"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `landing page to revalidate a conditional head request`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
let eTag = try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
try #require(response.headers[.eTag])
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .head,
|
||||||
|
headers: [.ifNoneMatch: eTag]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notModified)
|
||||||
|
#expect(response.body.readableBytes == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `security headers to be applied to the landing page`() async throws {
|
func `security headers to be applied to the landing page`() async throws {
|
||||||
try await app(
|
try await app(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Infrastructure
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import WebsiteLibrary
|
||||||
@@ -8,7 +9,6 @@ struct StaticFileTests {
|
|||||||
// MARK: Type aliases
|
// MARK: Type aliases
|
||||||
|
|
||||||
typealias File = StaticFile
|
typealias File = StaticFile
|
||||||
typealias FileExtension = StaticFile.Extension
|
|
||||||
|
|
||||||
// MARK: Computed tests
|
// MARK: Computed tests
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ struct StaticFileTests {
|
|||||||
))
|
))
|
||||||
func `file extensions`(
|
func `file extensions`(
|
||||||
for file: File,
|
for file: File,
|
||||||
expects extensions: [FileExtension]
|
expects extensions: [AssetExtension]
|
||||||
) {
|
) {
|
||||||
#expect(file.fileExtensions == extensions)
|
#expect(file.fileExtensions == extensions)
|
||||||
}
|
}
|
||||||
@@ -34,81 +34,6 @@ struct StaticFileTests {
|
|||||||
#expect(file.fileName == fileName)
|
#expect(file.fileName == fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(arguments: zip(
|
|
||||||
Self.extensions,
|
|
||||||
Self.contentTypes
|
|
||||||
))
|
|
||||||
func `content type`(
|
|
||||||
for fileExtension: FileExtension,
|
|
||||||
expects contentType: String
|
|
||||||
) {
|
|
||||||
#expect(fileExtension.contentType == contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(arguments: zip(
|
|
||||||
Self.extensions,
|
|
||||||
Self.subdirectories
|
|
||||||
))
|
|
||||||
func `subdirectory`(
|
|
||||||
for fileExtension: FileExtension,
|
|
||||||
expects subdirectory: String?
|
|
||||||
) {
|
|
||||||
#expect(fileExtension.subdirectory == subdirectory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Method tests
|
|
||||||
|
|
||||||
@Test(arguments: zip(
|
|
||||||
File.allCases,
|
|
||||||
Self.relativePaths
|
|
||||||
))
|
|
||||||
func `relative path for`(
|
|
||||||
for file: File,
|
|
||||||
expects relativePaths: [String]
|
|
||||||
) {
|
|
||||||
for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) {
|
|
||||||
#expect(file.relativePath(for: fileExtension) == relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(arguments: zip(
|
|
||||||
File.allCases,
|
|
||||||
Self.relativePaths
|
|
||||||
))
|
|
||||||
func `url path for`(
|
|
||||||
for file: File,
|
|
||||||
expects relativePaths: [String]
|
|
||||||
) {
|
|
||||||
for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) {
|
|
||||||
#expect(file.urlPath(for: fileExtension) == "/\(relativePath)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(arguments: [
|
|
||||||
"",
|
|
||||||
".",
|
|
||||||
"Resources/Static"
|
|
||||||
])
|
|
||||||
func `path relative to`(
|
|
||||||
_ basePath: String
|
|
||||||
) {
|
|
||||||
for file in File.allCases {
|
|
||||||
for fileExtension in file.fileExtensions {
|
|
||||||
let pathRelativeToBasePath = file.path(
|
|
||||||
relativeTo: basePath,
|
|
||||||
for: fileExtension
|
|
||||||
)
|
|
||||||
let relativePath = file.relativePath(for: fileExtension)
|
|
||||||
|
|
||||||
if basePath.isEmpty {
|
|
||||||
#expect(pathRelativeToBasePath == relativePath)
|
|
||||||
} else {
|
|
||||||
#expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: CaseIterable tests
|
// MARK: CaseIterable tests
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -124,37 +49,7 @@ private extension StaticFileTests {
|
|||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
|
|
||||||
static let extensions: [FileExtension] = [
|
static let fileExtensions: [[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
|
|
||||||
]
|
|
||||||
static let fileExtensions: [[FileExtension]] = [
|
|
||||||
[.png],
|
[.png],
|
||||||
[.css, .js],
|
[.css, .js],
|
||||||
[.ico],
|
[.ico],
|
||||||
@@ -180,18 +75,5 @@ private extension StaticFileTests {
|
|||||||
"site",
|
"site",
|
||||||
"sitemap"
|
"sitemap"
|
||||||
]
|
]
|
||||||
static let relativePaths: [[String]] = [
|
|
||||||
["apple-touch-icon.png"],
|
|
||||||
["css/error.css", "js/error.js"],
|
|
||||||
["favicon.ico"],
|
|
||||||
["icon.svg"],
|
|
||||||
["icon-192.png"],
|
|
||||||
["icon-512.png"],
|
|
||||||
["css/index.css", "js/index.js"],
|
|
||||||
["robots.txt"],
|
|
||||||
["css/shared.css", "js/shared.js"],
|
|
||||||
["site.webmanifest"],
|
|
||||||
["sitemap.xml"]
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,18 @@ struct IndexPageTests {
|
|||||||
#expect(html.contains("/js/index.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"))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
|
import Infrastructure
|
||||||
import NIOCore
|
import NIOCore
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -42,4 +43,117 @@ struct RootControllerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the landing page with revalidation headers`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let eTag = try #require(response.headers[.eTag])
|
||||||
|
|
||||||
|
#expect(eTag.hasPrefix(#"W/""#))
|
||||||
|
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `revalidates a matching conditional request with a 304`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
let eTag = try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
try #require(response.headers[.eTag])
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: eTag]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notModified)
|
||||||
|
#expect(response.headers[.eTag] == eTag)
|
||||||
|
#expect(response.body.readableBytes == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the full page to a non-matching conditional request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: #"W/"0123456789abcdef""#]
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(body.contains("Hello world!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders versioned asset URLs when given a version`() async throws {
|
||||||
|
try await app(
|
||||||
|
assetVersion: "0123456789abcdef"
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/index.css?v=0123456789abcdef"))
|
||||||
|
#expect(body.contains("/js/index.js?v=0123456789abcdef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders unversioned asset URLs by default`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"href="/css/index.css""#))
|
||||||
|
#expect(!body.contains("?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension RootControllerTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose root controller appends the given version token to the landing
|
||||||
|
/// page's asset URLs.
|
||||||
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||||
|
/// - Returns: the configured application.
|
||||||
|
func app(
|
||||||
|
assetVersion: String?
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||||
|
assetVersion: assetVersion
|
||||||
|
).routes)
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -4,6 +4,8 @@ import HummingbirdTesting
|
|||||||
import NIOCore
|
import NIOCore
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
|
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import HummingbirdTesting
|
|||||||
import NIOCore
|
import NIOCore
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
|
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
|
||||||
@@ -77,4 +79,95 @@ struct NotFoundMiddlewareTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders versioned asset URLs when given a version`() async throws {
|
||||||
|
try await app(
|
||||||
|
assetVersion: "0123456789abcdef"
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/error.css?v=0123456789abcdef"))
|
||||||
|
#expect(body.contains("/js/shared.js?v=0123456789abcdef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders unversioned asset URLs by default`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"href="/css/error.css""#))
|
||||||
|
#expect(!body.contains("?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the error page without revalidation headers`() async throws {
|
||||||
|
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||||
|
// invite revalidation with an entity tag or a cache policy.
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.eTag] == nil)
|
||||||
|
#expect(response.headers[.cacheControl] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the full error page to a conditional request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: "*"]
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(body.contains("Page Not Found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension NotFoundMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose not-found middleware appends the given version token to the
|
||||||
|
/// error page's asset URLs.
|
||||||
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||||
|
/// - Returns: the configured application.
|
||||||
|
func app(
|
||||||
|
assetVersion: String?
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware()
|
||||||
|
NotFoundMiddleware(
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user