Merge branch 'setup'
@@ -13,9 +13,10 @@
|
|||||||
# Xcode project (not used by the Linux build)
|
# Xcode project (not used by the Linux build)
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
|
||||||
# OS / editor cruft
|
# OS / editor / tooling cruft
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
|
.claude
|
||||||
|
|
||||||
# Local environment overrides and secrets (Compose still reads these from the
|
# Local environment overrides and secrets (Compose still reads these from the
|
||||||
# host at runtime; ignoring them here only keeps them out of the image build).
|
# host at runtime; ignoring them here only keeps them out of the image build).
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2700"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "Infrastructure"
|
||||||
|
BuildableName = "Infrastructure"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "InfrastructureTests"
|
||||||
|
BuildableName = "InfrastructureTests"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
queueDebuggingEnabled = "No">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "Infrastructure"
|
||||||
|
BuildableName = "Infrastructure"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// swift-tools-version: 6.3
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Infrastructure",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "Infrastructure",
|
||||||
|
targets: [
|
||||||
|
"Infrastructure"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
path: "../Localization"
|
||||||
|
),
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/elementary-swift/elementary.git",
|
||||||
|
from: "0.6.0"
|
||||||
|
),
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/hummingbird-project/hummingbird.git",
|
||||||
|
from: "2.25.0"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Infrastructure",
|
||||||
|
dependencies: [
|
||||||
|
.byName(name: "Localization"),
|
||||||
|
.product(
|
||||||
|
name: "Elementary",
|
||||||
|
package: "elementary"
|
||||||
|
),
|
||||||
|
.product(
|
||||||
|
name: "Hummingbird",
|
||||||
|
package: "hummingbird"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "InfrastructureTests",
|
||||||
|
dependencies: [
|
||||||
|
.byName(name: "Infrastructure"),
|
||||||
|
.product(
|
||||||
|
name: "Elementary",
|
||||||
|
package: "elementary"
|
||||||
|
),
|
||||||
|
.product(
|
||||||
|
name: "HummingbirdTesting",
|
||||||
|
package: "hummingbird"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
path: "Tests",
|
||||||
|
resources: [
|
||||||
|
// Copied verbatim rather than processed: the String Catalog is read as raw JSON at
|
||||||
|
// runtime so it resolves identically on Darwin and Linux (which cannot compile it).
|
||||||
|
.copy("Catalogs/Localizable.xcstrings")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Infrastructure
|
||||||
|
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The package provides, grouped by role:
|
||||||
|
| Role | Types |
|
||||||
|
| --- | --- |
|
||||||
|
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||||
|
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||||
|
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||||
|
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
|
||||||
|
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
|
||||||
|
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||||
|
| Contexts | `LocalizedRequestContext` |
|
||||||
|
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
|
||||||
|
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them.
|
||||||
|
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need.
|
||||||
|
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
Sources are split by visibility, then by kind, one type per file:
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── Public/ public API
|
||||||
|
│ ├── Builders/ RouteCollectionBuilder
|
||||||
|
│ ├── Enumerations/ AssetExtension
|
||||||
|
│ ├── Extensions/ addController, plus the default header names and values
|
||||||
|
│ ├── Methods/ FingerprintAssets
|
||||||
|
│ ├── Middlewares/ the five HTTP middlewares
|
||||||
|
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||||
|
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||||
|
│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/
|
||||||
|
└── Internal/
|
||||||
|
├── Extensions/ implementation details (the String separators)
|
||||||
|
└── Types/ implementation details (FNV1aHash)
|
||||||
|
Tests/
|
||||||
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux
|
||||||
|
└── Utils/ stubs (StubAsset, StubPage, …) and the suite Tag constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
|
- macOS 15, matching the sibling `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
extension String {
|
||||||
|
enum Separator {
|
||||||
|
static let comma = ","
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Hashes bytes with the FNV-1a 64-bit algorithm.
|
||||||
|
///
|
||||||
|
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive
|
||||||
|
/// restarts: the asset version token (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`).
|
||||||
|
/// It is not cryptographic — a collision only risks serving a stale cached asset, not security.
|
||||||
|
struct FNV1aHash {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The running hash value.
|
||||||
|
private var hash: UInt64
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a hasher at the FNV-1a offset basis.
|
||||||
|
init() {
|
||||||
|
self.hash = 0xcbf2_9ce4_8422_2325
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The hash of everything combined so far, as a fixed-width, 16-character hexadecimal token.
|
||||||
|
///
|
||||||
|
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
|
||||||
|
var digest: String {
|
||||||
|
String(
|
||||||
|
format: "%016llx",
|
||||||
|
hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
/// Folds the given bytes into the hash.
|
||||||
|
/// - Parameter bytes: the bytes to fold in.
|
||||||
|
mutating func combine(
|
||||||
|
_ bytes: some Sequence<UInt8>
|
||||||
|
) {
|
||||||
|
for byte in bytes {
|
||||||
|
hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
/// A result builder that collects the route collections of ``RouterController`` values into a stack.
|
||||||
|
///
|
||||||
|
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting controllers be listed declaratively rather than having
|
||||||
|
/// their routes added one statement at a time.
|
||||||
|
@resultBuilder
|
||||||
|
public enum RouteCollectionBuilder<Context: RequestContext> {
|
||||||
|
|
||||||
|
public static func buildExpression(
|
||||||
|
_ controller: some RouterController<Context>
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
[controller.routes]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildBlock(
|
||||||
|
_ collections: [RouteCollection<Context>]...
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
collections.flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildOptional(
|
||||||
|
_ collections: [RouteCollection<Context>]?
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
collections ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(
|
||||||
|
first collections: [RouteCollection<Context>]
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
collections
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(
|
||||||
|
second collections: [RouteCollection<Context>]
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
collections
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildArray(
|
||||||
|
_ collections: [[RouteCollection<Context>]]
|
||||||
|
) -> [RouteCollection<Context>] {
|
||||||
|
collections.flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/// A file extension used by an ``Asset``.
|
||||||
|
///
|
||||||
|
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths.
|
||||||
|
public enum AssetExtension: String, Sendable {
|
||||||
|
/// A Cascading Style Sheets file.
|
||||||
|
case css
|
||||||
|
/// A JavaScript file.
|
||||||
|
case js
|
||||||
|
/// A Portable Network Graphics image.
|
||||||
|
case png
|
||||||
|
/// A Windows icon image.
|
||||||
|
case ico
|
||||||
|
/// A Scalable Vector Graphics image.
|
||||||
|
case svg
|
||||||
|
/// A plain text file.
|
||||||
|
case txt
|
||||||
|
/// A web application manifest file.
|
||||||
|
case webmanifest
|
||||||
|
/// An Extensible Markup Language file.
|
||||||
|
case xml
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
public extension AssetExtension {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The file's content type.
|
||||||
|
var contentType: String {
|
||||||
|
switch self {
|
||||||
|
case .css: "text/css"
|
||||||
|
case .js: "text/javascript"
|
||||||
|
case .png: "image/png"
|
||||||
|
case .ico: "image/vnd.microsoft.icon"
|
||||||
|
case .svg: "image/svg+xml"
|
||||||
|
case .txt: "text/plain"
|
||||||
|
case .webmanifest: "application/manifest+json"
|
||||||
|
case .xml: "application/xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sub-directory within the static root that holds files with this extension, if any.
|
||||||
|
var subdirectory: String? {
|
||||||
|
switch self {
|
||||||
|
case .css: "css"
|
||||||
|
case .js: "js"
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,32 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
public extension RouterMethods {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder.
|
||||||
|
///
|
||||||
|
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// router.addController {
|
||||||
|
/// RootController<AppRequestContext>()
|
||||||
|
/// HealthController<AppRequestContext>()
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Each controller's route collection is added at the router's root, exactly as a sequence of `addRoutes(_:)` calls would.
|
||||||
|
/// - Parameter build: the controller stack result builder.
|
||||||
|
/// - Returns: the router, so calls can be chained.
|
||||||
|
@discardableResult
|
||||||
|
func addController(
|
||||||
|
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
|
||||||
|
) -> Self {
|
||||||
|
for collection in build() {
|
||||||
|
addRoutes(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
extension String {
|
||||||
|
/// A namespace for the security headers' default configuration values.
|
||||||
|
///
|
||||||
|
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is "sticky" in browsers, so it stays off unless explicitly
|
||||||
|
/// configured in production.
|
||||||
|
public enum Security {
|
||||||
|
/// The default `Content-Security-Policy`.
|
||||||
|
///
|
||||||
|
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document
|
||||||
|
/// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must
|
||||||
|
/// link external stylesheets.
|
||||||
|
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||||
|
public static let contentTypeOptions = "nosniff"
|
||||||
|
/// The default `X-Frame-Options` (forbids framing the page).
|
||||||
|
public static let frameOptions = "DENY"
|
||||||
|
/// The default `Referrer-Policy`.
|
||||||
|
public static let referrerPolicy = "strict-origin-when-cross-origin"
|
||||||
|
/// The default `Permissions-Policy` (denies access to powerful browser features a static site does not use).
|
||||||
|
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
/// Derives a version token from the contents of the static files directory.
|
||||||
|
///
|
||||||
|
/// The token folds every file under the directory — its relative path and its bytes, in a stable order — into one FNV-1a digest, so it changes whenever any
|
||||||
|
/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=<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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
import HTTPTypes
|
import HTTPTypes
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import Localization
|
import Localization
|
||||||
|
|
||||||
/// Resolves the visitor's preferred language and records it on the request context.
|
/// Resolves the visitor's preferred language and records it on the request context.
|
||||||
///
|
///
|
||||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's
|
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
|
||||||
/// `Accept-Language` header, negotiates the best supported match (falling back to the default
|
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
|
||||||
/// language), and stores it on the context's ``LocalizedRequestContext/language``.
|
|
||||||
///
|
///
|
||||||
/// The request is otherwise passed through untouched — the URL and routing are not affected — so
|
/// The request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its
|
||||||
/// each page is served at its existing path and varies its content by header.
|
/// content by header.
|
||||||
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -19,9 +19,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -3,13 +3,12 @@ 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 pages, the
|
||||||
/// response bubbles back up — the rendered landing page, the ``ErrorPage`` produced by
|
/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies the strict, hardened
|
||||||
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies
|
/// interpretation of the content instead of its lenient legacy defaults.
|
||||||
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
|
|
||||||
///
|
///
|
||||||
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
|
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of
|
||||||
/// every request, so the per-request cost is a handful of header copies.
|
/// header copies.
|
||||||
public struct SecurityHeadersMiddleware<Context: RequestContext> {
|
public struct SecurityHeadersMiddleware<Context: RequestContext> {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -20,9 +19,8 @@ public struct SecurityHeadersMiddleware<Context: RequestContext> {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a security-headers middleware.
|
/// Creates a security-headers middleware.
|
||||||
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
|
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with
|
||||||
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
|
/// `Strict-Transport-Security` left off (see ``Configuration``).
|
||||||
/// ``Configuration``).
|
|
||||||
public init(
|
public init(
|
||||||
configuration: Configuration = .init()
|
configuration: Configuration = .init()
|
||||||
) {
|
) {
|
||||||
@@ -37,23 +35,35 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
|||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
|
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Existing values for the same header names are replaced so downstream middleware cannot leave
|
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response
|
||||||
/// a weaker policy in place.
|
/// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers.
|
||||||
|
/// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place.
|
||||||
/// - Parameters:
|
/// - 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 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
|
||||||
@@ -91,10 +101,9 @@ private extension SecurityHeadersMiddleware.Configuration {
|
|||||||
extension SecurityHeadersMiddleware {
|
extension SecurityHeadersMiddleware {
|
||||||
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
|
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
|
||||||
///
|
///
|
||||||
/// Each property maps to a single response header. A `nil` value omits that header entirely,
|
/// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays
|
||||||
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
|
/// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on
|
||||||
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
|
/// (via configuration) only in TLS-terminated production.
|
||||||
/// switched on (via configuration) only in TLS-terminated production.
|
|
||||||
public struct Configuration: Sendable {
|
public struct Configuration: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -116,9 +125,8 @@ extension SecurityHeadersMiddleware {
|
|||||||
|
|
||||||
/// Creates a security-headers configuration.
|
/// Creates a security-headers configuration.
|
||||||
///
|
///
|
||||||
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
|
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults
|
||||||
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
|
/// to `nil` (omitted). Pass `nil` for any header to drop it from the response.
|
||||||
/// to drop it from the response.
|
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
|
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
|
||||||
/// - contentTypeOptions: the `X-Content-Type-Options` value.
|
/// - contentTypeOptions: the `X-Content-Type-Options` 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,78 @@
|
|||||||
|
/// An asset shipped with a website: a file stored under the static files root and served by Hummingbird's `FileMiddleware` middleware.
|
||||||
|
///
|
||||||
|
/// A conforming asset supplies its file name and the extensions it is available with, each resolving to its own file; the protocol derives the paths from them:
|
||||||
|
/// the file's path within the static files root and the URL path it is served at, optionally versioned to bust caches.
|
||||||
|
public protocol Asset: Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The file extensions the asset is available with.
|
||||||
|
var fileExtensions: [AssetExtension] { get }
|
||||||
|
|
||||||
|
/// The asset's file name, without extension.
|
||||||
|
var fileName: String { get }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
public extension Asset {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Resolves the asset's path against the given base directory.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - basePath: the directory the static files are served from.
|
||||||
|
/// - fileExtension: the extension of the file to resolve.
|
||||||
|
/// - Returns: the path to the file, relative to the `basePath` path.
|
||||||
|
func path(
|
||||||
|
relativeTo basePath: String,
|
||||||
|
for fileExtension: AssetExtension
|
||||||
|
) -> String {
|
||||||
|
let relativePath = relativePath(for: fileExtension)
|
||||||
|
|
||||||
|
guard !basePath.isEmpty else {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(basePath)/\(relativePath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`).
|
||||||
|
///
|
||||||
|
/// This also matches the URL path the file is served at by `FileMiddleware`.
|
||||||
|
///
|
||||||
|
/// - Parameter fileExtension: the extension of the file to resolve.
|
||||||
|
/// - Returns: the path to the file, relative to the static files root.
|
||||||
|
func relativePath(
|
||||||
|
for fileExtension: AssetExtension
|
||||||
|
) -> String {
|
||||||
|
let file = "\(fileName).\(fileExtension.rawValue)"
|
||||||
|
|
||||||
|
return fileExtension.subdirectory
|
||||||
|
.map { "\($0)/\(file)" } ?? file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`).
|
||||||
|
///
|
||||||
|
/// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): `FileMiddleware` ignores the query when
|
||||||
|
/// resolving the file, while caches key on the full URL, so a deploy that changes the assets busts every cached copy at once.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - fileExtension: the extension of the file to resolve.
|
||||||
|
/// - version: the version token to append, or `nil` to leave the URL unversioned.
|
||||||
|
/// - Returns: the path to use in `href` and `src` attributes.
|
||||||
|
func urlPath(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
version: String? = nil
|
||||||
|
) -> String {
|
||||||
|
let path = "/\(relativePath(for: fileExtension))"
|
||||||
|
|
||||||
|
guard let version, !version.isEmpty else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(path)?v=\(version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
/// A request context that carries the language negotiated for the request.
|
||||||
|
///
|
||||||
|
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` header and stores it here, so downstream
|
||||||
|
/// controllers and middleware can serve the matching localization without re-reading the header.
|
||||||
|
public protocol LocalizedRequestContext: RequestContext {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The language identifier negotiated for the request.
|
||||||
|
var language: String { get set }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
|
||||||
|
///
|
||||||
|
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
|
||||||
|
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
|
||||||
|
/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
|
||||||
|
public protocol Page: HTMLDocument, Sendable {
|
||||||
|
|
||||||
|
// MARK: Associated types
|
||||||
|
|
||||||
|
/// The type of the page's markup.
|
||||||
|
associatedtype Content: HTML
|
||||||
|
|
||||||
|
/// The type of the page's head metadata markup.
|
||||||
|
associatedtype Metadata: HTML
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
|
var assetVersion: String? { get }
|
||||||
|
|
||||||
|
/// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
|
||||||
|
var canonicalURL: String? { get }
|
||||||
|
|
||||||
|
/// The page's markup, rendered before the ``scripts``.
|
||||||
|
@HTMLBuilder
|
||||||
|
var content: Content { get }
|
||||||
|
|
||||||
|
/// The locale the page content is localized to.
|
||||||
|
var locale: Locale { get }
|
||||||
|
|
||||||
|
/// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: Metadata { get }
|
||||||
|
|
||||||
|
/// The scripts loaded at the end of the document body, in order.
|
||||||
|
var scripts: [any Asset] { get }
|
||||||
|
|
||||||
|
/// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
|
||||||
|
/// to omit them.
|
||||||
|
var socialCard: SocialCard? { get }
|
||||||
|
|
||||||
|
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
|
||||||
|
var structuredData: StructuredData? { get }
|
||||||
|
|
||||||
|
/// The stylesheets linked in the document head, in order.
|
||||||
|
var stylesheets: [any Asset] { get }
|
||||||
|
|
||||||
|
/// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag.
|
||||||
|
var summary: String? { get }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
public extension Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The canonical URL is omitted unless the page provides one.
|
||||||
|
var canonicalURL: String? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page ``content`` followed by its ``scripts``.
|
||||||
|
@HTMLBuilder
|
||||||
|
var body: some HTML {
|
||||||
|
content
|
||||||
|
|
||||||
|
for file in scripts {
|
||||||
|
script(.src(file.urlPath(
|
||||||
|
for: .js,
|
||||||
|
version: assetVersion
|
||||||
|
))) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
|
||||||
|
/// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
|
||||||
|
///
|
||||||
|
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
|
||||||
|
/// and HTML5 allows only one.
|
||||||
|
///
|
||||||
|
/// The structured data is an inert data block — browsers never execute it, so a site's `Content-Security-Policy` does not apply to it —
|
||||||
|
/// that search engines read for the organization's name, logo, and profiles.
|
||||||
|
@HTMLBuilder
|
||||||
|
var head: some HTML {
|
||||||
|
meta(
|
||||||
|
.name(.viewport),
|
||||||
|
.content("width=device-width, initial-scale=1")
|
||||||
|
)
|
||||||
|
|
||||||
|
if let summary {
|
||||||
|
meta(
|
||||||
|
.name(.description),
|
||||||
|
.content(summary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let canonicalURL {
|
||||||
|
link(
|
||||||
|
.rel("canonical"),
|
||||||
|
.href(canonicalURL)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let socialCard {
|
||||||
|
for tag in socialCard.tags {
|
||||||
|
meta(
|
||||||
|
.custom(
|
||||||
|
name: tag.attribute.rawValue,
|
||||||
|
value: tag.name.rawValue
|
||||||
|
),
|
||||||
|
.content(tag.content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let structuredData {
|
||||||
|
script(.custom(
|
||||||
|
name: "type",
|
||||||
|
value: "application/ld+json"
|
||||||
|
)) {
|
||||||
|
HTMLRaw(structuredData.payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata
|
||||||
|
|
||||||
|
for file in stylesheets {
|
||||||
|
link(
|
||||||
|
.rel(.stylesheet),
|
||||||
|
.href(file.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: assetVersion
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The social card is omitted unless the page provides one.
|
||||||
|
var socialCard: SocialCard? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The structured data is omitted unless the page provides one.
|
||||||
|
var structuredData: StructuredData? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The summary is omitted unless the page provides one.
|
||||||
|
var summary: String? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
/// A type exposing its endpoints as a route collection ready to be added to a router.
|
||||||
|
///
|
||||||
|
/// Conforming controllers group related endpoints behind a single ``routes`` property, so the application composes them declaratively with
|
||||||
|
/// ``Hummingbird/RouterMethods/addController(_:)``:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// struct HealthController<Context: RequestContext>: RouterController {
|
||||||
|
/// var routes: RouteCollection<Context> {
|
||||||
|
/// RouteCollection(context: Context.self)
|
||||||
|
/// .get("health") { _, _ in HTTPResponse.Status.ok }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// router.addController {
|
||||||
|
/// HealthController<AppRequestContext>()
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
public protocol RouterController<Context>: Sendable {
|
||||||
|
|
||||||
|
// MARK: Associated types
|
||||||
|
|
||||||
|
/// The request context the controller's routes operate on.
|
||||||
|
associatedtype Context: RequestContext
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The collection of routes the controller exposes.
|
||||||
|
var routes: RouteCollection<Context> { get }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,15 +6,14 @@ import Localization
|
|||||||
|
|
||||||
/// A per-language collection of pre-rendered HTML responses.
|
/// A per-language collection of pre-rendered HTML responses.
|
||||||
///
|
///
|
||||||
/// At initialization it renders the document once for each language the bundle's ``LanguageList``
|
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
|
||||||
/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by
|
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
|
||||||
/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so
|
/// `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.
|
public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||||
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 +23,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 +40,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 +50,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:
|
||||||
/// - Returns: the cached response for the language, the default language's response when the
|
/// - language: the negotiated language identifier.
|
||||||
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
|
/// - request: the request the response answers, consulted for conditional revalidation.
|
||||||
func response(
|
/// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a
|
||||||
for language: String
|
/// `500 Internal Server Error` if neither is cached.
|
||||||
|
public func response(
|
||||||
|
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,102 @@
|
|||||||
|
/// The content of a page's link-preview card, rendered as Open Graph and Twitter meta tags in the document head.
|
||||||
|
///
|
||||||
|
/// The card carries the facts a link scraper reads — the page's title, summary, URL, and share image — and derives the meta ``tags`` expressing
|
||||||
|
/// them. Scrapers require absolute URLs, so the card takes ``url`` and ``Image/url`` fully formed; composing them from an origin and a versioned
|
||||||
|
/// asset path stays with the page providing the card.
|
||||||
|
public struct SocialCard: Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The card's share image, or `nil` to omit its tags.
|
||||||
|
public let image: Image?
|
||||||
|
|
||||||
|
/// The locale of the card's text, or `nil` to omit its tag.
|
||||||
|
///
|
||||||
|
/// Open Graph specifies the `language_TERRITORY` form (e.g. `en_US`); scrapers also accept a bare language code (e.g. `en`).
|
||||||
|
public let locale: String?
|
||||||
|
|
||||||
|
/// The name of the site the card belongs to, or `nil` to omit its tag.
|
||||||
|
public let siteName: String?
|
||||||
|
|
||||||
|
/// The layout a Twitter card scraper gives the card.
|
||||||
|
public let style: Style
|
||||||
|
|
||||||
|
/// The card's summary, or `nil` to omit its tag.
|
||||||
|
public let summary: String?
|
||||||
|
|
||||||
|
/// The card's title.
|
||||||
|
public let title: String
|
||||||
|
|
||||||
|
/// The Open Graph type of the object the card describes.
|
||||||
|
public let type: String
|
||||||
|
|
||||||
|
/// The absolute URL the card's page is served at, or `nil` to omit its tag.
|
||||||
|
public let url: String?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a link-preview card.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - title: the card's title.
|
||||||
|
/// - summary: the card's summary, or `nil` (the default) to omit its tag.
|
||||||
|
/// - url: the absolute URL the card's page is served at, or `nil` (the default) to omit its tag.
|
||||||
|
/// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag.
|
||||||
|
/// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default)
|
||||||
|
/// to omit its tag.
|
||||||
|
/// - image: the card's share image, or `nil` (the default) to omit its tags.
|
||||||
|
/// - type: the Open Graph type of the object the card describes. Defaults to `website`.
|
||||||
|
/// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``.
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
summary: String? = nil,
|
||||||
|
url: String? = nil,
|
||||||
|
siteName: String? = nil,
|
||||||
|
locale: String? = nil,
|
||||||
|
image: Image? = nil,
|
||||||
|
type: String = "website",
|
||||||
|
style: Style = .summaryLargeImage
|
||||||
|
) {
|
||||||
|
self.image = image
|
||||||
|
self.locale = locale
|
||||||
|
self.siteName = siteName
|
||||||
|
self.style = style
|
||||||
|
self.summary = summary
|
||||||
|
self.title = title
|
||||||
|
self.type = type
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and
|
||||||
|
/// the Twitter card style last. A tag whose fact the card does not carry is left out.
|
||||||
|
public var tags: [Tag] {
|
||||||
|
let tags: [Tag?] = [
|
||||||
|
Tag(type, name: .type),
|
||||||
|
siteName.map { Tag($0, name: .siteName) },
|
||||||
|
Tag(title, name: .title),
|
||||||
|
summary.map { Tag($0, name: .description) },
|
||||||
|
url.map { Tag($0, name: .url) },
|
||||||
|
locale.map { Tag($0, name: .locale) },
|
||||||
|
] + (image?.tags ?? []) + [
|
||||||
|
Tag(style.rawValue, name: .twitter),
|
||||||
|
]
|
||||||
|
|
||||||
|
return tags.compactMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
public extension SocialCard {
|
||||||
|
|
||||||
|
/// The layout a Twitter card scraper gives a ``SocialCard``.
|
||||||
|
enum Style: String, Sendable {
|
||||||
|
/// A compact card with a small thumbnail.
|
||||||
|
case summary
|
||||||
|
/// A card with a large image above the text.
|
||||||
|
case summaryLargeImage = "summary_large_image"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
extension SocialCard {
|
||||||
|
/// The share image of a ``SocialCard``.
|
||||||
|
public struct Image: Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The image's text for assistive technologies, or `nil` to omit it.
|
||||||
|
public let alt: String?
|
||||||
|
|
||||||
|
/// The image's height in pixels, letting scrapers lay the card out before fetching the image.
|
||||||
|
public let height: Int
|
||||||
|
|
||||||
|
/// The absolute URL the image is served at.
|
||||||
|
public let url: String
|
||||||
|
|
||||||
|
/// The image's width in pixels, letting scrapers lay the card out before fetching the image.
|
||||||
|
public let width: Int
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a share image.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: the absolute URL the image is served at.
|
||||||
|
/// - width: the image's width in pixels.
|
||||||
|
/// - height: the image's height in pixels.
|
||||||
|
/// - alt: the image's text for assistive technologies, or `nil` (the default) to omit it.
|
||||||
|
public init(
|
||||||
|
url: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
alt: String? = nil
|
||||||
|
) {
|
||||||
|
self.alt = alt
|
||||||
|
self.height = height
|
||||||
|
self.url = url
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one.
|
||||||
|
public var tags: [Tag] {
|
||||||
|
let tags: [Tag?] = [
|
||||||
|
Tag(url, name: .image),
|
||||||
|
Tag(String(width), name: .imageWidth),
|
||||||
|
Tag(String(height), name: .imageHeight),
|
||||||
|
alt.map { Tag($0, name: .imageAlt) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return tags.compactMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
extension SocialCard {
|
||||||
|
/// A head meta tag of a ``SocialCard``: its name and content, keyed by the attribute its ``name`` dictates.
|
||||||
|
public struct Tag: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The tag's value.
|
||||||
|
public let content: String
|
||||||
|
|
||||||
|
/// The tag's name.
|
||||||
|
public let name: Name
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a head meta tag.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: the tag's value.
|
||||||
|
/// - name: the tag's name, dictating the attribute the tag is keyed by.
|
||||||
|
public init(
|
||||||
|
_ content: String,
|
||||||
|
name: Name
|
||||||
|
) {
|
||||||
|
self.content = content
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The attribute the tag is keyed by, dictated by its ``name``.
|
||||||
|
public var attribute: Attribute {
|
||||||
|
name.attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
extension SocialCard.Tag {
|
||||||
|
|
||||||
|
/// The meta attribute a ``SocialCard/Tag`` is keyed by, named by its raw value.
|
||||||
|
public enum Attribute: String, Sendable {
|
||||||
|
/// The `name` attribute, keying the Twitter tags.
|
||||||
|
case name
|
||||||
|
/// The `property` attribute, keying the Open Graph tags.
|
||||||
|
case property
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name of a ``SocialCard/Tag``, carried in its raw value.
|
||||||
|
public enum Name: String, Sendable {
|
||||||
|
/// The `og:description` tag, carrying the card's summary.
|
||||||
|
case description = "og:description"
|
||||||
|
/// The `og:image` tag, carrying the share image's absolute URL.
|
||||||
|
case image = "og:image"
|
||||||
|
/// The `og:image:alt` tag, carrying the share image's text for assistive technologies.
|
||||||
|
case imageAlt = "og:image:alt"
|
||||||
|
/// The `og:image:height` tag, carrying the share image's height in pixels.
|
||||||
|
case imageHeight = "og:image:height"
|
||||||
|
/// The `og:image:width` tag, carrying the share image's width in pixels.
|
||||||
|
case imageWidth = "og:image:width"
|
||||||
|
/// The `og:locale` tag, carrying the locale of the card's text.
|
||||||
|
case locale = "og:locale"
|
||||||
|
/// The `og:site_name` tag, carrying the name of the site the card belongs to.
|
||||||
|
case siteName = "og:site_name"
|
||||||
|
/// The `og:title` tag, carrying the card's title.
|
||||||
|
case title = "og:title"
|
||||||
|
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
|
||||||
|
case twitter = "twitter:card"
|
||||||
|
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
|
||||||
|
case type = "og:type"
|
||||||
|
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
|
||||||
|
case url = "og:url"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
public extension SocialCard.Tag.Name {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
|
||||||
|
var attribute: SocialCard.Tag.Attribute {
|
||||||
|
self == .twitter ? .name : .property
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/// The structured data of a page, rendered as a JSON-LD script in the document head.
|
||||||
|
///
|
||||||
|
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
|
||||||
|
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
|
||||||
|
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
|
||||||
|
///
|
||||||
|
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
|
||||||
|
/// page providing the data.
|
||||||
|
public struct StructuredData: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The schema.org nodes of the data's graph, in the order they render.
|
||||||
|
public let nodes: [Node]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates structured data from the nodes of its graph.
|
||||||
|
/// - Parameter nodes: the schema.org nodes of the data's graph, in the order they render.
|
||||||
|
public init(
|
||||||
|
nodes: [Node]
|
||||||
|
) {
|
||||||
|
self.nodes = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||||
|
///
|
||||||
|
/// The values are rendered as JSON string literals with `<` escaped as well, so a value can never close the `script` tag embedding
|
||||||
|
/// the payload.
|
||||||
|
public var payload: String {
|
||||||
|
#"{"@context":"https://schema.org","@graph":[\#(fragments)]}"#
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initializers
|
||||||
|
|
||||||
|
public extension StructuredData {
|
||||||
|
|
||||||
|
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
|
||||||
|
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
|
||||||
|
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
|
||||||
|
/// A property whose fact the data does not carry is left out.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: the name of the organization and the site.
|
||||||
|
/// - url: the absolute URL the site is served at.
|
||||||
|
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
|
||||||
|
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
|
||||||
|
init(
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
logo: String? = nil,
|
||||||
|
profiles: [String] = []
|
||||||
|
) {
|
||||||
|
let id = url + "#organization"
|
||||||
|
|
||||||
|
var organization: [Property] = [
|
||||||
|
.init(.name, value: .string(name)),
|
||||||
|
.init(.url, value: .string(url)),
|
||||||
|
]
|
||||||
|
|
||||||
|
if let logo {
|
||||||
|
organization.append(.init(.logo, value:.string(logo)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !profiles.isEmpty {
|
||||||
|
organization.append(.init(
|
||||||
|
.sameAs,
|
||||||
|
value: .array(profiles.map(Value.string))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(nodes: [
|
||||||
|
.init(
|
||||||
|
type: .organization,
|
||||||
|
id: id,
|
||||||
|
properties: organization
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
type: .website,
|
||||||
|
properties: [
|
||||||
|
.init(.name, value: .string(name)),
|
||||||
|
.init(.url, value: .string(url)),
|
||||||
|
.init(.publisher, value: .reference(id)),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension StructuredData {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var fragments: String {
|
||||||
|
nodes
|
||||||
|
.map(\.fragment)
|
||||||
|
.joined(separator: .Separator.comma)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
extension StructuredData {
|
||||||
|
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
|
||||||
|
public struct Node: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The node's identifier, rendered as its `@id` property, or `nil` to omit it.
|
||||||
|
///
|
||||||
|
/// Another node references this node through ``Value/reference(_:)`` with the same identifier.
|
||||||
|
public let id: String?
|
||||||
|
|
||||||
|
/// The node's properties, in the order they render after the type and identifier.
|
||||||
|
public let properties: [Property]
|
||||||
|
|
||||||
|
/// The node's schema.org type (e.g. ``Kind/organization``), rendered as its `@type` property.
|
||||||
|
public let type: Kind
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a node.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - type: the node's schema.org type (e.g. ``Kind/organization``).
|
||||||
|
/// - id: the node's identifier, or `nil` (the default) to omit it.
|
||||||
|
/// - properties: the node's properties, in the order they render.
|
||||||
|
public init(
|
||||||
|
type: Kind,
|
||||||
|
id: String? = nil,
|
||||||
|
properties: [Property]
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.properties = properties
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The node's minified JSON object: the `@type`, the `@id` (when the node carries one), and the ``properties`` in order.
|
||||||
|
var fragment: String {
|
||||||
|
var members = [#""@type":\#(Value.literal(type.rawValue))"#]
|
||||||
|
|
||||||
|
if let id {
|
||||||
|
members.append(#""@id":\#(Value.literal(id))"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
members += properties.map(\.fragment)
|
||||||
|
|
||||||
|
return "{\(members.joined(separator: .Separator.comma))}"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structures
|
||||||
|
|
||||||
|
extension StructuredData.Node {
|
||||||
|
/// The schema.org type of a ``StructuredData/Node``.
|
||||||
|
///
|
||||||
|
/// Schema.org's vocabulary is open, so the kind is a typed string rather than a closed enumeration: the kinds every service shares
|
||||||
|
/// come as constants, a service declares the kinds its own node shapes need in an extension, and a one-off kind can be spelled as a
|
||||||
|
/// string literal.
|
||||||
|
public struct Kind: Equatable, ExpressibleByStringLiteral, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The type as it renders in the payload.
|
||||||
|
public let rawValue: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a kind.
|
||||||
|
/// - Parameter rawValue: the type as it renders in the payload.
|
||||||
|
public init(_ rawValue: String) {
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a kind from a string literal.
|
||||||
|
/// - Parameter value: the type as it renders in the payload.
|
||||||
|
public init(stringLiteral value: String) {
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
public extension StructuredData.Node.Kind {
|
||||||
|
/// An organization, e.g. the one publishing a website.
|
||||||
|
static let organization: Self = "Organization"
|
||||||
|
/// A website.
|
||||||
|
static let website: Self = "WebSite"
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
extension StructuredData {
|
||||||
|
/// A named property of a ``Node``, in the position it renders.
|
||||||
|
public struct Property: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The property's schema.org name (e.g. `sameAs`).
|
||||||
|
public let name: Name
|
||||||
|
|
||||||
|
/// The property's value.
|
||||||
|
public let value: Value
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a property.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: the property's schema.org name (e.g. `sameAs`).
|
||||||
|
/// - value: the property's value.
|
||||||
|
public init(
|
||||||
|
_ name: Name,
|
||||||
|
value: Value
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The property's minified JSON member: its name and its rendered value.
|
||||||
|
var fragment: String {
|
||||||
|
#"\#(Value.literal(name.rawValue)):\#(value.fragment)"#
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structures
|
||||||
|
|
||||||
|
extension StructuredData.Property {
|
||||||
|
/// The schema.org name of a ``StructuredData/Property``.
|
||||||
|
///
|
||||||
|
/// Schema.org's vocabulary is open, so the name is a typed string rather than a closed enumeration: the names every service shares
|
||||||
|
/// come as constants, a service declares the names its own node shapes need in an extension, and a one-off name can be spelled as a
|
||||||
|
/// string literal.
|
||||||
|
public struct Name: Equatable, ExpressibleByStringLiteral, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The name as it renders in the payload.
|
||||||
|
public let rawValue: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a name.
|
||||||
|
/// - Parameter rawValue: the name as it renders in the payload.
|
||||||
|
public init(_ rawValue: String) {
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a name from a string literal.
|
||||||
|
/// - Parameter value: the name as it renders in the payload.
|
||||||
|
public init(stringLiteral value: String) {
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
public extension StructuredData.Property.Name {
|
||||||
|
/// The absolute URL of an organization's logo.
|
||||||
|
static let logo: Self = "logo"
|
||||||
|
/// The name of the thing a node describes.
|
||||||
|
static let name: Self = "name"
|
||||||
|
/// The organization publishing a website.
|
||||||
|
static let publisher: Self = "publisher"
|
||||||
|
/// The absolute URLs of the profiles that also identify the thing a node describes.
|
||||||
|
static let sameAs: Self = "sameAs"
|
||||||
|
/// The absolute URL of the thing a node describes.
|
||||||
|
static let url: Self = "url"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
extension StructuredData {
|
||||||
|
/// A value of a ``Property``: a string, a list, a nested node, or a reference to another node.
|
||||||
|
///
|
||||||
|
/// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script`
|
||||||
|
/// tag embedding the payload it renders into.
|
||||||
|
public indirect enum Value: Equatable, Sendable {
|
||||||
|
/// A list of values.
|
||||||
|
case array([Value])
|
||||||
|
/// A nested node, e.g. the place a schema.org event is located at.
|
||||||
|
case node(Node)
|
||||||
|
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
|
||||||
|
case reference(String)
|
||||||
|
/// A string value.
|
||||||
|
case string(String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension StructuredData.Value {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The value's minified JSON fragment.
|
||||||
|
var fragment: String {
|
||||||
|
switch self {
|
||||||
|
case .array(let values):
|
||||||
|
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
|
||||||
|
case .node(let node):
|
||||||
|
node.fragment
|
||||||
|
case .reference(let id):
|
||||||
|
#"{"@id":\#(Self.literal(id))}"#
|
||||||
|
case .string(let string):
|
||||||
|
Self.literal(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Renders a string as a JSON string literal, escaping `<` as well since the payload is embedded in a `script` tag the string
|
||||||
|
/// could otherwise close.
|
||||||
|
/// - Parameter value: the string to render.
|
||||||
|
/// - Returns: the quoted and escaped literal.
|
||||||
|
static func literal(_ value: String) -> String {
|
||||||
|
var literal = "\""
|
||||||
|
|
||||||
|
for scalar in value.unicodeScalars {
|
||||||
|
switch scalar {
|
||||||
|
case "\"":
|
||||||
|
literal += #"\""#
|
||||||
|
case "\\":
|
||||||
|
literal += #"\\"#
|
||||||
|
case "<":
|
||||||
|
literal += #"\u003c"#
|
||||||
|
case let scalar where scalar.value < 0x20:
|
||||||
|
let hex = String(scalar.value, radix: 16)
|
||||||
|
|
||||||
|
literal += #"\u"# + String(repeating: "0", count: 4 - hex.count) + hex
|
||||||
|
default:
|
||||||
|
literal.unicodeScalars.append(scalar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return literal + "\""
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"FNV1aHash type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct FNV1aHashTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
("", "cbf29ce484222325"),
|
||||||
|
("a", "af63dc4c8601ec8c"),
|
||||||
|
("b", "af63df4c8601f1a5"),
|
||||||
|
("foobar", "85944171f73967e8"),
|
||||||
|
])
|
||||||
|
func `matches the published FNV-1a 64-bit test vectors`(
|
||||||
|
input: String,
|
||||||
|
digest: String
|
||||||
|
) {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
hash.combine(input.utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest == digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `pads the digest to sixteen characters`() {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
// "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep.
|
||||||
|
hash.combine("aa".utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest == "089c4307b54596b7")
|
||||||
|
#expect(hash.digest.count == 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `hashes incrementally combined bytes as one stream`() {
|
||||||
|
var combined = FNV1aHash()
|
||||||
|
var whole = FNV1aHash()
|
||||||
|
|
||||||
|
combined.combine("foo".utf8)
|
||||||
|
combined.combine("bar".utf8)
|
||||||
|
whole.combine("foobar".utf8)
|
||||||
|
|
||||||
|
#expect(combined.digest == whole.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `distinguishes the order of the combined bytes`() {
|
||||||
|
var forward = FNV1aHash()
|
||||||
|
var backward = FNV1aHash()
|
||||||
|
|
||||||
|
forward.combine("ab".utf8)
|
||||||
|
backward.combine("ba".utf8)
|
||||||
|
|
||||||
|
#expect(forward.digest != backward.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `digests without consuming the running hash`() {
|
||||||
|
var hash = FNV1aHash()
|
||||||
|
|
||||||
|
hash.combine("foo".utf8)
|
||||||
|
|
||||||
|
let first = hash.digest
|
||||||
|
|
||||||
|
#expect(hash.digest == first)
|
||||||
|
|
||||||
|
hash.combine("bar".utf8)
|
||||||
|
|
||||||
|
#expect(hash.digest != first)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"AssetExtension enumeration",
|
||||||
|
.tags(.asset)
|
||||||
|
)
|
||||||
|
struct AssetExtensionTests {
|
||||||
|
|
||||||
|
// MARK: Computed tests
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
Self.extensions,
|
||||||
|
Self.contentTypes
|
||||||
|
))
|
||||||
|
func `content type`(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
expects contentType: String
|
||||||
|
) {
|
||||||
|
#expect(fileExtension.contentType == contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
Self.extensions,
|
||||||
|
Self.subdirectories
|
||||||
|
))
|
||||||
|
func `subdirectory`(
|
||||||
|
for fileExtension: AssetExtension,
|
||||||
|
expects subdirectory: String?
|
||||||
|
) {
|
||||||
|
#expect(fileExtension.subdirectory == subdirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension AssetExtensionTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
static let extensions: [AssetExtension] = [
|
||||||
|
.css,
|
||||||
|
.js,
|
||||||
|
.png,
|
||||||
|
.ico,
|
||||||
|
.svg,
|
||||||
|
.txt,
|
||||||
|
.webmanifest,
|
||||||
|
.xml
|
||||||
|
]
|
||||||
|
static let contentTypes: [String] = [
|
||||||
|
"text/css",
|
||||||
|
"text/javascript",
|
||||||
|
"image/png",
|
||||||
|
"image/vnd.microsoft.icon",
|
||||||
|
"image/svg+xml",
|
||||||
|
"text/plain",
|
||||||
|
"application/manifest+json",
|
||||||
|
"application/xml"
|
||||||
|
]
|
||||||
|
static let subdirectories: [String?] = [
|
||||||
|
"css",
|
||||||
|
"js",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import NIOCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"addController method",
|
||||||
|
.tags(.`extension`)
|
||||||
|
)
|
||||||
|
struct RouterMethodsTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `adds the routes of every listed controller`() async throws {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addController {
|
||||||
|
StubController(path: "first")
|
||||||
|
StubController(path: "second")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/first",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body) == "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/second",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body) == "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [true, false])
|
||||||
|
func `adds a controller behind a condition only when the condition holds`(
|
||||||
|
condition: Bool
|
||||||
|
) async throws {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addController {
|
||||||
|
StubController(path: "always")
|
||||||
|
|
||||||
|
if condition {
|
||||||
|
StubController(path: "conditional")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/always",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/conditional",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == (condition ? .ok : .notFound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [true, false])
|
||||||
|
func `adds only the taken branch of a condition`(
|
||||||
|
takesFirst: Bool
|
||||||
|
) async throws {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addController {
|
||||||
|
if takesFirst {
|
||||||
|
StubController(path: "first")
|
||||||
|
} else {
|
||||||
|
StubController(path: "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/first",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == (takesFirst ? .ok : .notFound))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/second",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == (takesFirst ? .notFound : .ok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `adds a controller for every iteration of a loop`() async throws {
|
||||||
|
let paths = ["one", "two", "three"]
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addController {
|
||||||
|
for path in paths {
|
||||||
|
StubController(path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router) { client in
|
||||||
|
for path in paths {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/\(path)",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body) == path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `returns the router so calls can be chained`() async throws {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router
|
||||||
|
.addController {
|
||||||
|
StubController(path: "first")
|
||||||
|
}
|
||||||
|
.addController {
|
||||||
|
StubController(path: "second")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/first",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/second",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"FingerprintAssets method",
|
||||||
|
.tags(.asset)
|
||||||
|
)
|
||||||
|
struct FingerprintAssetsTests {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let fingerprint = FingerprintAssets()
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `fingerprints the files under a directory`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }",
|
||||||
|
"robots.txt": "User-agent: *"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = try #require(fingerprint(directory.path))
|
||||||
|
|
||||||
|
#expect(token.count == 16)
|
||||||
|
#expect(token.allSatisfy { $0.isHexDigit })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `agrees across directories with identical contents`() throws {
|
||||||
|
let files = [
|
||||||
|
"css/site.css": "body { margin: 0; }",
|
||||||
|
"js/site.js": "console.log(1);"
|
||||||
|
]
|
||||||
|
let first = try makeDirectory(files: files)
|
||||||
|
let second = try makeDirectory(files: files)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(first)
|
||||||
|
removeDirectory(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(first.path) == fingerprint(second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file's contents change`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = fingerprint(directory.path)
|
||||||
|
|
||||||
|
try "body { margin: 1px; }".write(
|
||||||
|
to: directory.appendingPathComponent("css/site.css"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) != before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file is renamed`() throws {
|
||||||
|
let contents = "body { margin: 0; }"
|
||||||
|
let first = try makeDirectory(files: ["css/site.css": contents])
|
||||||
|
let second = try makeDirectory(files: ["css/main.css": contents])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(first)
|
||||||
|
removeDirectory(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(first.path) != fingerprint(second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `changes the token when a file is added`() throws {
|
||||||
|
let directory = try makeDirectory(files: [
|
||||||
|
"css/site.css": "body { margin: 0; }"
|
||||||
|
])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = fingerprint(directory.path)
|
||||||
|
|
||||||
|
try "console.log(1);".write(
|
||||||
|
to: directory.appendingPathComponent("site.js"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) != before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `returns nil for a directory without files`() throws {
|
||||||
|
let directory = try makeDirectory(files: [:])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
removeDirectory(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(fingerprint(directory.path) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `returns nil for a missing directory`() {
|
||||||
|
let missing = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
#expect(fingerprint(missing.path) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension FingerprintAssetsTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Creates a unique temporary directory holding the given files, keyed by relative path.
|
||||||
|
/// - Parameter files: the files to create, keyed by their path relative to the directory.
|
||||||
|
/// - Returns: the URL of the created directory.
|
||||||
|
func makeDirectory(
|
||||||
|
files: [String: String]
|
||||||
|
) throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
for (relativePath, contents) in files {
|
||||||
|
let file = directory.appendingPathComponent(relativePath)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: file.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try contents.write(
|
||||||
|
to: file,
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a temporary directory created by ``makeDirectory(files:)``.
|
||||||
|
/// - Parameter directory: the URL of the directory to remove.
|
||||||
|
func removeDirectory(
|
||||||
|
_ directory: URL
|
||||||
|
) {
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
import HTTPTypes
|
import HTTPTypes
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
import NIOCore
|
import NIOCore
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import Infrastructure
|
||||||
|
|
||||||
@Suite("LocalizationMiddleware middleware")
|
@Suite(
|
||||||
|
"LocalizationMiddleware middleware",
|
||||||
|
.tags(.middleware)
|
||||||
|
)
|
||||||
struct LocalizationMiddlewareTests {
|
struct LocalizationMiddlewareTests {
|
||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
|
|
||||||
private let app: Application = .init(router: {
|
private let app: Application = .init(router: {
|
||||||
let router = Router(context: WebsiteRequestContext.self)
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
router.addMiddleware {
|
router.addMiddleware {
|
||||||
LocalizationMiddleware()
|
LocalizationMiddleware(bundle: .module)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("language") { _, context in
|
router.get("language") { _, context in
|
||||||
@@ -33,9 +37,9 @@ struct LocalizationMiddlewareTests {
|
|||||||
try await client.execute(
|
try await client.execute(
|
||||||
uri: "/language",
|
uri: "/language",
|
||||||
method: .get,
|
method: .get,
|
||||||
headers: [.acceptLanguage: "en-US,en;q=0.9"]
|
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||||
) { response in
|
) { response in
|
||||||
#expect(String(buffer: response.body) == "en")
|
#expect(String(buffer: response.body) == "de")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,4 +56,17 @@ struct LocalizationMiddlewareTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the default for an unsupported language`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/language",
|
||||||
|
method: .get,
|
||||||
|
headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
|
||||||
|
) { response in
|
||||||
|
#expect(String(buffer: response.body) == "en")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import Foundation
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import NIOCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"NotFoundMiddleware middleware",
|
||||||
|
.tags(.middleware)
|
||||||
|
)
|
||||||
|
struct NotFoundMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
private let app: Application = .init(router: {
|
||||||
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware(bundle: .module)
|
||||||
|
NotFoundMiddleware(bundle: .module) {
|
||||||
|
StubPage(locale: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("hello") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("boom") { _, _ -> String in
|
||||||
|
throw HTTPError(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}())
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the error page for an unmatched request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||||
|
#expect(response.headers[.contentLanguage] == "en")
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language")
|
||||||
|
#expect(body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the error page in the negotiated language`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get,
|
||||||
|
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.contentLanguage] == "de")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `passes a matched response through untouched`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.body == ByteBuffer(string: "Hello!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `rethrows a non-not-found error unchanged`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/boom",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .badRequest)
|
||||||
|
#expect(!body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders versioned asset URLs when given a version`() async throws {
|
||||||
|
try await app(
|
||||||
|
assetVersion: "0123456789abcdef"
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("/css/stub.css?v=0123456789abcdef"))
|
||||||
|
#expect(body.contains("/js/stub.js?v=0123456789abcdef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders unversioned asset URLs by default`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"href="/css/stub.css""#))
|
||||||
|
#expect(!body.contains("?v="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the error page without revalidation headers`() async throws {
|
||||||
|
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||||
|
// invite revalidation with an entity tag or a cache policy.
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(response.headers[.eTag] == nil)
|
||||||
|
#expect(response.headers[.cacheControl] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the full error page to a conditional request`() async throws {
|
||||||
|
try await app.test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/this-path-does-not-exist",
|
||||||
|
method: .get,
|
||||||
|
headers: [.ifNoneMatch: "*"]
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
#expect(body.contains("Stub content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension NotFoundMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose not-found middleware appends the given version token to the
|
||||||
|
/// error page's asset URLs.
|
||||||
|
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||||
|
/// - Returns: the configured application.
|
||||||
|
func app(
|
||||||
|
assetVersion: String?
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router(context: StubRequestContext.self)
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
LocalizationMiddleware(bundle: .module)
|
||||||
|
NotFoundMiddleware(bundle: .module) {
|
||||||
|
StubPage(
|
||||||
|
locale: $0,
|
||||||
|
assetVersion: assetVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"RateLimitMiddleware middleware",
|
||||||
|
.tags(.middleware)
|
||||||
|
)
|
||||||
|
struct RateLimitMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `admits requests within the limit`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 3)
|
||||||
|
).test(.router) { client in
|
||||||
|
for _ in 1 ... 3 {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `rejects a request over the limit with a retry-after header`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 2)
|
||||||
|
).test(.router) { client in
|
||||||
|
for _ in 1 ... 2 {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
|
||||||
|
let retryAfter = try #require(response.headers[.retryAfter])
|
||||||
|
|
||||||
|
#expect(try #require(Int(retryAfter)) >= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `admits requests again once the window resets`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(
|
||||||
|
limit: 1,
|
||||||
|
window: .milliseconds(50)
|
||||||
|
)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `separates clients by their forwarded address when trusted`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(
|
||||||
|
limit: 1,
|
||||||
|
trustForwardedFor: true
|
||||||
|
)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.8"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
// The first entry names the client; the appended proxy hop must not change its key.
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `ignores the forwarded address when not trusted`() async throws {
|
||||||
|
try await app(
|
||||||
|
configuration: .init(limit: 1)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.7"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
// Without trust (and without a connection address in router-only testing), every client
|
||||||
|
// shares one bucket, so a rotated header must not mint a fresh budget.
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/hello",
|
||||||
|
method: .get,
|
||||||
|
headers: [.xForwardedFor: "203.0.113.8"]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .tooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension RateLimitMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose router applies the rate-limit middleware ahead of a single
|
||||||
|
/// `/hello` route returning a plain body.
|
||||||
|
func app(
|
||||||
|
configuration: RateLimitMiddleware<BasicRequestContext>.Configuration
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
RateLimitMiddleware(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("hello") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import Hummingbird
|
|||||||
import HummingbirdTesting
|
import HummingbirdTesting
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import WebsiteLibrary
|
@testable import Infrastructure
|
||||||
|
|
||||||
@Suite("SecurityHeadersMiddleware middleware")
|
@Suite(
|
||||||
|
"SecurityHeadersMiddleware middleware",
|
||||||
|
.tags(.middleware)
|
||||||
|
)
|
||||||
struct SecurityHeadersMiddlewareTests {
|
struct SecurityHeadersMiddlewareTests {
|
||||||
|
|
||||||
// MARK: Functional tests
|
// MARK: Functional tests
|
||||||
@@ -17,11 +20,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 +94,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 +124,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 +149,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,134 @@
|
|||||||
|
import HTTPTypes
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"VaryMiddleware middleware",
|
||||||
|
.tags(.middleware)
|
||||||
|
)
|
||||||
|
struct VaryMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `adds the default field to a response without a vary header`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/plain",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `appends to an existing vary header`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/localized",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language, Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `does not duplicate a name already present`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/encoded",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `matches an existing name regardless of its casing`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/lowercased",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "accept-encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `normalizes the whitespace of an existing list`() async throws {
|
||||||
|
try await app().test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/spaced",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `appends every configured field`() async throws {
|
||||||
|
try await app(
|
||||||
|
fields: [.acceptEncoding, .acceptLanguage]
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/plain",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.headers[.vary] == "Accept-Encoding, Accept-Language")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension VaryMiddlewareTests {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Builds an application whose router applies the vary middleware ahead of routes whose
|
||||||
|
/// responses carry different `Vary` starting points: `/plain` none, `/localized` an
|
||||||
|
/// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase
|
||||||
|
/// `accept-encoding`, and `/spaced` a list with irregular whitespace.
|
||||||
|
func app(
|
||||||
|
fields: [HTTPField.Name] = [.acceptEncoding]
|
||||||
|
) -> some ApplicationProtocol {
|
||||||
|
let router = Router()
|
||||||
|
|
||||||
|
router.addMiddleware {
|
||||||
|
VaryMiddleware(fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("plain") { _, _ in
|
||||||
|
"Hello!"
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, vary) in [
|
||||||
|
("localized", "Accept-Language"),
|
||||||
|
("encoded", "Accept-Encoding"),
|
||||||
|
("lowercased", "accept-encoding"),
|
||||||
|
("spaced", "Accept-Language , User-Agent"),
|
||||||
|
] {
|
||||||
|
router.get(RouterPath(path)) { _, _ -> Response in
|
||||||
|
var response = Response(status: .ok)
|
||||||
|
|
||||||
|
response.headers[.vary] = vary
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application(router: router)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Asset protocol",
|
||||||
|
.tags(.`protocol`)
|
||||||
|
)
|
||||||
|
struct AssetTests {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let image = StubAsset(
|
||||||
|
fileExtensions: [.png],
|
||||||
|
fileName: "icon"
|
||||||
|
)
|
||||||
|
private let shared = StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: Method tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `relative path nests the file inside its extension's sub-directory`() {
|
||||||
|
#expect(shared.relativePath(for: .css) == "css/shared.css")
|
||||||
|
#expect(shared.relativePath(for: .js) == "js/shared.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `relative path keeps the file at the root without a sub-directory`() {
|
||||||
|
#expect(image.relativePath(for: .png) == "icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `url path prefixes the relative path with a slash`() {
|
||||||
|
#expect(shared.urlPath(for: .css) == "/css/shared.css")
|
||||||
|
#expect(image.urlPath(for: .png) == "/icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `url path appends a version token as a query parameter`() {
|
||||||
|
#expect(shared.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: "0123456789abcdef"
|
||||||
|
) == "/css/shared.css?v=0123456789abcdef")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [nil, ""] as [String?])
|
||||||
|
func `url path without a version`(
|
||||||
|
version: String?
|
||||||
|
) {
|
||||||
|
#expect(shared.urlPath(
|
||||||
|
for: .css,
|
||||||
|
version: version
|
||||||
|
) == "/css/shared.css")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"Resources/Static"
|
||||||
|
])
|
||||||
|
func `path relative to`(
|
||||||
|
_ basePath: String
|
||||||
|
) {
|
||||||
|
for fileExtension in shared.fileExtensions {
|
||||||
|
let pathRelativeToBasePath = shared.path(
|
||||||
|
relativeTo: basePath,
|
||||||
|
for: fileExtension
|
||||||
|
)
|
||||||
|
let relativePath = shared.relativePath(for: fileExtension)
|
||||||
|
|
||||||
|
if basePath.isEmpty {
|
||||||
|
#expect(pathRelativeToBasePath == relativePath)
|
||||||
|
} else {
|
||||||
|
#expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Page protocol",
|
||||||
|
.tags(.`protocol`)
|
||||||
|
)
|
||||||
|
struct PageTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `assembles the document around the page's parts`() {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
#expect(html.contains("<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 `omits the summary and canonical tags by default`() {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
#expect(!html.contains(#"name="description""#))
|
||||||
|
#expect(!html.contains(#"rel="canonical""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the summary and canonical tags when provided`() {
|
||||||
|
let html = StubPage(
|
||||||
|
canonicalURL: "https://stub.example/",
|
||||||
|
summary: "A stub page."
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"<meta name="description" content="A stub page.">"#))
|
||||||
|
#expect(html.contains(#"<link rel="canonical" href="https://stub.example/">"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `omits the social card tags by default`() {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
#expect(!html.contains(#"property="og:"#))
|
||||||
|
#expect(!html.contains(#"name="twitter:card""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the social card tags when provided`() {
|
||||||
|
let html = StubPage(socialCard: .init(
|
||||||
|
title: "Stub Page",
|
||||||
|
summary: "A stub page.",
|
||||||
|
url: "https://stub.example/",
|
||||||
|
image: .init(
|
||||||
|
url: "https://stub.example/img/card.png",
|
||||||
|
width: 2400,
|
||||||
|
height: 1260
|
||||||
|
)
|
||||||
|
)).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"<meta property="og:type" content="website">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:title" content="Stub Page">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:description" content="A stub page.">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:url" content="https://stub.example/">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:image" content="https://stub.example/img/card.png">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:image:width" content="2400">"#))
|
||||||
|
#expect(html.contains(#"<meta property="og:image:height" content="1260">"#))
|
||||||
|
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `omits the structured data script by default`() {
|
||||||
|
let html = StubPage().render()
|
||||||
|
|
||||||
|
#expect(!html.contains("application/ld+json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the structured data script when provided`() {
|
||||||
|
let html = StubPage(structuredData: .init(
|
||||||
|
name: "Stub Site",
|
||||||
|
url: "https://stub.example/",
|
||||||
|
logo: "https://stub.example/logo.png",
|
||||||
|
profiles: ["https://social.example/stub"]
|
||||||
|
)).render()
|
||||||
|
|
||||||
|
#expect(html.contains(
|
||||||
|
#"<script type="application/ld+json">"# +
|
||||||
|
#"{"@context":"https://schema.org","@graph":["# +
|
||||||
|
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
|
||||||
|
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
|
||||||
|
#"</script>"#
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,91 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"SocialCard type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct SocialCardTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `derives the full tag list from a complete card`() {
|
||||||
|
let card = SocialCard(
|
||||||
|
title: "A Title",
|
||||||
|
summary: "A summary.",
|
||||||
|
url: "https://site.example/",
|
||||||
|
siteName: "A Site",
|
||||||
|
locale: "en",
|
||||||
|
image: .init(
|
||||||
|
url: "https://site.example/img/card.png",
|
||||||
|
width: 2400,
|
||||||
|
height: 1260,
|
||||||
|
alt: "An image."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(card.tags == [
|
||||||
|
.init("website", name: .type),
|
||||||
|
.init("A Site", name: .siteName),
|
||||||
|
.init("A Title", name: .title),
|
||||||
|
.init("A summary.", name: .description),
|
||||||
|
.init("https://site.example/", name: .url),
|
||||||
|
.init("en", name: .locale),
|
||||||
|
.init("https://site.example/img/card.png", name: .image),
|
||||||
|
.init("2400", name: .imageWidth),
|
||||||
|
.init("1260", name: .imageHeight),
|
||||||
|
.init("An image.", name: .imageAlt),
|
||||||
|
.init("summary_large_image", name: .twitter),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `omits the tags of the facts a minimal card does not carry`() {
|
||||||
|
let card = SocialCard(title: "A Title")
|
||||||
|
|
||||||
|
#expect(card.tags == [
|
||||||
|
.init("website", name: .type),
|
||||||
|
.init("A Title", name: .title),
|
||||||
|
.init("summary_large_image", name: .twitter),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `omits the image alt tag when the image carries none`() {
|
||||||
|
let card = SocialCard(
|
||||||
|
title: "A Title",
|
||||||
|
image: .init(
|
||||||
|
url: "https://site.example/img/card.png",
|
||||||
|
width: 2400,
|
||||||
|
height: 1260
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let names = card.tags.map(\.name)
|
||||||
|
|
||||||
|
#expect(names.contains(.image))
|
||||||
|
#expect(!names.contains(.imageAlt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `carries the type and style it is given`() {
|
||||||
|
let card = SocialCard(
|
||||||
|
title: "A Title",
|
||||||
|
type: "article",
|
||||||
|
style: .summary
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(card.tags.contains(.init("article", name: .type)))
|
||||||
|
#expect(card.tags.contains(.init("summary", name: .twitter)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keys a tag by the attribute its name dictates`() {
|
||||||
|
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
|
||||||
|
#expect(SocialCard.Tag.Name.title.attribute == .property)
|
||||||
|
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"StructuredData type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct StructuredDataTests {
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `derives the full payload from complete data`() {
|
||||||
|
let data = StructuredData(
|
||||||
|
name: "A Site",
|
||||||
|
url: "https://site.example/",
|
||||||
|
logo: "https://site.example/logo.png",
|
||||||
|
profiles: [
|
||||||
|
"https://social.example/a-site",
|
||||||
|
"https://videos.example/a-site",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||||
|
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
|
||||||
|
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
|
||||||
|
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `omits the properties of the facts minimal data does not carry`() {
|
||||||
|
let data = StructuredData(
|
||||||
|
name: "A Site",
|
||||||
|
url: "https://site.example/"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||||
|
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
|
||||||
|
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders a composed node graph`() {
|
||||||
|
let data = StructuredData(nodes: [
|
||||||
|
.init(
|
||||||
|
type: "MusicEvent",
|
||||||
|
properties: [
|
||||||
|
.init(.name, value: .string("A Gig")),
|
||||||
|
.init("location", value: .node(.init(
|
||||||
|
type: "Place",
|
||||||
|
properties: [
|
||||||
|
.init(.name, value: .string("A Venue")),
|
||||||
|
]
|
||||||
|
))),
|
||||||
|
.init("organizer", value: .reference("https://site.example/#organization")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||||
|
#"{"@type":"MusicEvent","name":"A Gig","location":{"@type":"Place","name":"A Venue"},"# +
|
||||||
|
#""organizer":{"@id":"https://site.example/#organization"}}]}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `escapes the values it embeds in the payload`() {
|
||||||
|
let data = StructuredData(
|
||||||
|
name: #"A "Quoted" \ Site"#,
|
||||||
|
url: "https://site.example/</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
|
||||||
|
// The `<` is escaped so a value can never close the script tag embedding the payload.
|
||||||
|
#expect(!data.payload.contains("</script>"))
|
||||||
|
#expect(data.payload.contains("\\" + "u003c/script>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders a node fragment with its identifier`() {
|
||||||
|
let node = StructuredData.Node(
|
||||||
|
type: "Organization",
|
||||||
|
id: "https://site.example/#organization",
|
||||||
|
properties: [
|
||||||
|
.init(.name, value: .string("A Site")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(node.fragment == #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site"}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders a node fragment without an identifier or properties`() {
|
||||||
|
let node = StructuredData.Node(
|
||||||
|
type: "Organization",
|
||||||
|
properties: []
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(node.fragment == #"{"@type":"Organization"}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the common names and kinds by their schema.org spelling`() {
|
||||||
|
#expect(StructuredData.Property.Name.logo.rawValue == "logo")
|
||||||
|
#expect(StructuredData.Property.Name.name.rawValue == "name")
|
||||||
|
#expect(StructuredData.Property.Name.publisher.rawValue == "publisher")
|
||||||
|
#expect(StructuredData.Property.Name.sameAs.rawValue == "sameAs")
|
||||||
|
#expect(StructuredData.Property.Name.url.rawValue == "url")
|
||||||
|
#expect(StructuredData.Node.Kind.organization.rawValue == "Organization")
|
||||||
|
#expect(StructuredData.Node.Kind.website.rawValue == "WebSite")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the fragment of every value case`() {
|
||||||
|
#expect(StructuredData.Value.string("A Value").fragment == #""A Value""#)
|
||||||
|
#expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#)
|
||||||
|
#expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#)
|
||||||
|
#expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders a string as a quoted literal`() {
|
||||||
|
#expect(StructuredData.Value.literal("A Value") == #""A Value""#)
|
||||||
|
#expect(StructuredData.Value.literal("") == "\"\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `pads the escape of a control character to four digits`() {
|
||||||
|
#expect(StructuredData.Value.literal("\u{0}") == "\"" + "\\" + "u0000" + "\"")
|
||||||
|
#expect(StructuredData.Value.literal("\u{1f}") == "\"" + "\\" + "u001f" + "\"")
|
||||||
|
#expect(StructuredData.Value.literal("\u{a}") == "\"" + "\\" + "u000a" + "\"")
|
||||||
|
// The first scalar past the control range passes through untouched.
|
||||||
|
#expect(StructuredData.Value.literal(" ") == #"" ""#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `derives a payload that parses back to the facts it carries`() throws {
|
||||||
|
let name = "A \"Site\"\nwith \\ every <hazard>"
|
||||||
|
let data = StructuredData(
|
||||||
|
name: name,
|
||||||
|
url: "https://site.example/</script>",
|
||||||
|
logo: "https://site.example/logo.png",
|
||||||
|
profiles: ["https://social.example/a-site"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
|
||||||
|
let graph = try #require((object as? [String: Any])?["@graph"] as? [[String: Any]])
|
||||||
|
|
||||||
|
#expect(graph.count == 2)
|
||||||
|
#expect(graph[0]["name"] as? String == name)
|
||||||
|
#expect(graph[0]["sameAs"] as? [String] == ["https://social.example/a-site"])
|
||||||
|
#expect(graph[1]["url"] as? String == "https://site.example/</script>")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,30 @@
|
|||||||
|
import Hummingbird
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
|
/// A controller serving its path back as plain text, used to observe route registration.
|
||||||
|
struct StubController {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The path the controller serves, also returned as the response body.
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RouterController
|
||||||
|
|
||||||
|
extension StubController: RouterController {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
var routes: RouteCollection<BasicRequestContext> {
|
||||||
|
let routes = RouteCollection(context: BasicRequestContext.self)
|
||||||
|
|
||||||
|
routes.get(.init(path)) { _, _ in
|
||||||
|
self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
/// Tests exercising the asset scaffolding of the Infrastructure package.
|
||||||
|
@Tag static var asset: Tag
|
||||||
|
/// Tests exercising an extension of the Infrastructure package.
|
||||||
|
@Tag static var `extension`: Tag
|
||||||
|
/// Tests exercising a middleware of the Infrastructure package.
|
||||||
|
@Tag static var middleware: Tag
|
||||||
|
/// Tests exercising a protocol scaffolding of the Infrastructure package.
|
||||||
|
@Tag static var `protocol`: Tag
|
||||||
|
/// Tests exercising a type of the Infrastructure package.
|
||||||
|
@Tag static var type: Tag
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
|
/// A ``Page`` with fixed content, metadata, and stub assets.
|
||||||
|
struct StubPage: Page {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
|
let assetVersion: String?
|
||||||
|
|
||||||
|
/// The canonical URL rendered in the document head, or `nil` to omit it.
|
||||||
|
let canonicalURL: String?
|
||||||
|
|
||||||
|
/// The locale the page content is localized to.
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
|
||||||
|
let socialCard: SocialCard?
|
||||||
|
|
||||||
|
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
|
||||||
|
let structuredData: StructuredData?
|
||||||
|
|
||||||
|
/// The summary rendered in the document head, or `nil` to omit it.
|
||||||
|
let summary: String?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a stub page.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - locale: the locale the page content is localized to. Defaults to `en`.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||||
|
/// default) to leave them unversioned.
|
||||||
|
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
|
||||||
|
/// to omit it.
|
||||||
|
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
||||||
|
/// (the default) to omit them.
|
||||||
|
/// - structuredData: the structured data rendered as a JSON-LD script in the document
|
||||||
|
/// head, or `nil` (the default) to omit it.
|
||||||
|
/// - summary: the summary rendered in the document head, or `nil` (the default)
|
||||||
|
/// to omit it.
|
||||||
|
init(
|
||||||
|
locale: Locale = .init(identifier: "en"),
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
canonicalURL: String? = nil,
|
||||||
|
socialCard: SocialCard? = nil,
|
||||||
|
structuredData: StructuredData? = nil,
|
||||||
|
summary: String? = nil
|
||||||
|
) {
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.canonicalURL = canonicalURL
|
||||||
|
self.locale = locale
|
||||||
|
self.socialCard = socialCard
|
||||||
|
self.structuredData = structuredData
|
||||||
|
self.summary = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
p { "Stub content" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var lang: String {
|
||||||
|
locale.language.languageCode?.identifier ?? "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("stub"),
|
||||||
|
.content("marker")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "stub"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StubAsset(
|
||||||
|
fileExtensions: [.css, .js],
|
||||||
|
fileName: "stub"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Stub Page"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Localization
|
||||||
|
The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The package provides, grouped by role:
|
||||||
|
| Role | Types |
|
||||||
|
| --- | --- |
|
||||||
|
| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
|
||||||
|
| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 |
|
||||||
|
| Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines |
|
||||||
|
| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`. Adding a language is a translation-only change — once a locale exists in the catalog, `LanguageList` and `Negotiate` pick it up with no code change.
|
||||||
|
- **The locale is always explicit.** A server has no single "current" locale, so every lookup names the locale to resolve in; nothing reads process-wide locale state.
|
||||||
|
- **Raw `.xcstrings` parsing, for Linux parity.** The catalog is decoded from its JSON rather than through Foundation's compiled-catalog APIs, which are unavailable or non-functional on Linux. Consumers must `.copy` the catalog resource verbatim (not `.process` it) so it ships as raw JSON on every platform. Only simple `stringUnit` values are decoded; plural and device variations are not represented.
|
||||||
|
- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` once at startup and warn when it is not `.loaded`, before visitors ever see raw keys.
|
||||||
|
- **Method structs.** `Localize` and `Negotiate` hold their lifetime-fixed configuration (the bundle) in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
- **One decoded catalog per bundle.** Catalogs are immutable at runtime, so every `Localize`, `Negotiate`, and `LanguageList` bound to the same bundle and table shares one cached `StringCatalog`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
Sources are split by visibility, then by kind, one type per file:
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── Public/
|
||||||
|
│ ├── Enumerations/ CatalogState
|
||||||
|
│ ├── Methods/ Localize, Negotiate
|
||||||
|
│ └── Types/ LanguageList
|
||||||
|
└── Internal/
|
||||||
|
├── Protocols/ CatalogResolving, the seam between the public API and the catalog backend
|
||||||
|
└── Types/ StringCatalog (the cached .xcstrings decoder), LanguageRange
|
||||||
|
Tests/
|
||||||
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux
|
||||||
|
└── Utils/ the StubCatalog resolver and the suite Tag constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
Every suite carries a tag naming the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
|
- macOS 15, matching the sibling `Infrastructure` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
|
- No package dependencies — Foundation and Synchronization only.
|
||||||
@@ -2,23 +2,24 @@ import Foundation
|
|||||||
|
|
||||||
/// A backend that resolves localized strings for an explicit locale and reports the languages it serves.
|
/// A backend that resolves localized strings for an explicit locale and reports the languages it serves.
|
||||||
///
|
///
|
||||||
/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored
|
/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored and resolved. The shipping implementation,
|
||||||
/// and resolved. The shipping implementation, ``StringCatalog``, reads a raw `.xcstrings` catalog so it
|
/// ``StringCatalog``, reads a raw `.xcstrings` catalog so it behaves identically on Darwin and Linux. A future backend — for example one built on
|
||||||
/// behaves identically on Darwin and Linux. A future backend — for example one built on
|
/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform without changing any caller.
|
||||||
/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform
|
|
||||||
/// without changing any caller.
|
|
||||||
///
|
///
|
||||||
/// Resolution never fails: an implementation returns the key itself when it has no localization for it,
|
/// Resolution never fails: an implementation returns the key itself when it has no localization for it, mirroring Foundation's `String(localized:)`.
|
||||||
/// mirroring Foundation's `String(localized:)`. This is the contract both a dictionary lookup and the
|
/// This is the contract both a dictionary lookup and the native API can honour, since the native API cannot distinguish a missing key from a translation that
|
||||||
/// native API can honour, since the native API cannot distinguish a missing key from a translation that
|
|
||||||
/// happens to equal the key.
|
/// happens to equal the key.
|
||||||
protocol CatalogResolving: Sendable {
|
protocol CatalogResolving: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The source (development) language, used as the final fallback when a locale has no localization.
|
/// The source (development) language, used as the final fallback when a locale has no localization and as the default language a ``LanguageList``
|
||||||
|
/// serves.
|
||||||
var sourceLanguage: String { get }
|
var sourceLanguage: String { get }
|
||||||
|
|
||||||
|
/// The outcome of reading the backing catalog, for consumers to surface at startup.
|
||||||
|
var state: CatalogState { get }
|
||||||
|
|
||||||
/// Every language the backend can resolve strings for, including the source language.
|
/// Every language the backend can resolve strings for, including the source language.
|
||||||
var languages: Set<String> { get }
|
var languages: Set<String> { get }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// A single language range parsed from an `Accept-Language` header entry.
|
||||||
|
///
|
||||||
|
/// A range pairs the language tag a client asked for with the `q` weight expressing how much the client prefers it, as RFC 9110 defines them.
|
||||||
|
/// ``Negotiate`` parses each comma-separated header entry into one of these, then orders the ranges by descending weight so the most preferred tag
|
||||||
|
/// is matched first.
|
||||||
|
struct LanguageRange {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The language tag the client asked for, such as `de` or `de-AT`, or the `*` wildcard.
|
||||||
|
let tag: String
|
||||||
|
|
||||||
|
/// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when an entry names no weight).
|
||||||
|
let quality: Double
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources.
|
/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources.
|
||||||
///
|
///
|
||||||
@@ -18,6 +19,9 @@ struct StringCatalog: Sendable {
|
|||||||
/// The resolved entries, keyed by catalog key then by language code.
|
/// The resolved entries, keyed by catalog key then by language code.
|
||||||
let entries: [String: [String: String]]
|
let entries: [String: [String: String]]
|
||||||
|
|
||||||
|
/// The outcome of reading the catalog from its bundle.
|
||||||
|
let state: CatalogState
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// Every language the catalog provides a localization for, including the source language.
|
/// Every language the catalog provides a localization for, including the source language.
|
||||||
@@ -46,8 +50,9 @@ struct StringCatalog: Sendable {
|
|||||||
forResource: table,
|
forResource: table,
|
||||||
withExtension: .Extension.stringCatalog
|
withExtension: .Extension.stringCatalog
|
||||||
) else {
|
) else {
|
||||||
self.sourceLanguage = "en"
|
self.sourceLanguage = .Default.sourceLanguage
|
||||||
self.entries = [:]
|
self.entries = [:]
|
||||||
|
self.state = .missing
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +70,59 @@ struct StringCatalog: Sendable {
|
|||||||
(entry.localizations ?? [:])
|
(entry.localizations ?? [:])
|
||||||
.compactMapValues { $0.stringUnit?.value }
|
.compactMapValues { $0.stringUnit?.value }
|
||||||
}
|
}
|
||||||
|
self.state = .loaded
|
||||||
} catch {
|
} catch {
|
||||||
self.sourceLanguage = "en"
|
self.sourceLanguage = .Default.sourceLanguage
|
||||||
self.entries = [:]
|
self.entries = [:]
|
||||||
|
self.state = .undecodable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache
|
||||||
|
|
||||||
|
extension StringCatalog {
|
||||||
|
|
||||||
|
/// A cache key identifying a catalog by its bundle location and table name.
|
||||||
|
private struct Key: Hashable, Sendable {
|
||||||
|
let bundle: URL
|
||||||
|
let table: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decoded catalogs, keyed by bundle and table.
|
||||||
|
private static let cache = Mutex<[Key: StringCatalog]>([:])
|
||||||
|
|
||||||
|
/// Returns the catalog for the given bundle and table, decoding it on first access.
|
||||||
|
///
|
||||||
|
/// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` bound to the same bundle shares one
|
||||||
|
/// decoded catalog instead of re-reading its JSON.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: the bundle whose resources contain the String Catalog.
|
||||||
|
/// - table: the name of the String Catalog resource, without the `.xcstrings` extension.
|
||||||
|
/// - Returns: the decoded catalog, from the cache when it has been read before.
|
||||||
|
static func cached(
|
||||||
|
bundle: Bundle,
|
||||||
|
table: String = "Localizable"
|
||||||
|
) -> StringCatalog {
|
||||||
|
let key = Key(
|
||||||
|
bundle: bundle.bundleURL,
|
||||||
|
table: table
|
||||||
|
)
|
||||||
|
|
||||||
|
return cache.withLock { cache in
|
||||||
|
if let catalog = cache[key] {
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog = StringCatalog(
|
||||||
|
bundle: bundle,
|
||||||
|
table: table
|
||||||
|
)
|
||||||
|
|
||||||
|
cache[key] = catalog
|
||||||
|
|
||||||
|
return catalog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +132,8 @@ struct StringCatalog: Sendable {
|
|||||||
|
|
||||||
extension StringCatalog: CatalogResolving {
|
extension StringCatalog: CatalogResolving {
|
||||||
|
|
||||||
|
/// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its primary language code (`pt`), then the source
|
||||||
|
/// language, then the key itself. Tags are matched case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for.
|
||||||
func string(
|
func string(
|
||||||
for key: String,
|
for key: String,
|
||||||
in locale: Locale
|
in locale: Locale
|
||||||
@@ -85,15 +142,46 @@ extension StringCatalog: CatalogResolving {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
let language = locale
|
for tag in locale.catalogTags {
|
||||||
.language
|
if let match = byLanguage.first(
|
||||||
.languageCode?
|
where: { $0.key.lowercased() == tag }
|
||||||
.identifier
|
) {
|
||||||
?? sourceLanguage
|
return match.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return byLanguage[language]
|
return byLanguage[sourceLanguage] ?? key
|
||||||
?? byLanguage[sourceLanguage]
|
}
|
||||||
?? key
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locale+Extensions
|
||||||
|
|
||||||
|
private extension Locale {
|
||||||
|
|
||||||
|
/// The language tags to resolve a catalog entry against, most specific first.
|
||||||
|
///
|
||||||
|
/// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes `pt-br`), followed by its primary language code when
|
||||||
|
/// the two differ.
|
||||||
|
var catalogTags: [String] {
|
||||||
|
var tags: [String] = []
|
||||||
|
let identifier = identifier
|
||||||
|
.replacingOccurrences(
|
||||||
|
of: String.Separator.underscore,
|
||||||
|
with: String.Separator.dash
|
||||||
|
)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
if !identifier.isEmpty {
|
||||||
|
tags.append(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let code = language.languageCode?.identifier.lowercased(),
|
||||||
|
code != identifier {
|
||||||
|
tags.append(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -127,7 +215,18 @@ private extension StringCatalog {
|
|||||||
// MARK: - String+Constants
|
// MARK: - String+Constants
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
/// The source language assumed when a catalog is missing or cannot be decoded, matching the
|
||||||
|
/// package's default localization.
|
||||||
|
enum Default {
|
||||||
|
static let sourceLanguage = "en"
|
||||||
|
}
|
||||||
|
|
||||||
enum Extension {
|
enum Extension {
|
||||||
static let stringCatalog = "xcstrings"
|
static let stringCatalog = "xcstrings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Separator {
|
||||||
|
static let dash = "-"
|
||||||
|
static let underscore = "_"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// The outcome of reading a String Catalog from its bundle.
|
||||||
|
///
|
||||||
|
/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the language list falls back to the default language — so
|
||||||
|
/// nothing fails at the call site. This state is the signal a server checks once at startup to warn before visitors ever see raw localization keys.
|
||||||
|
public enum CatalogState: Equatable, Sendable {
|
||||||
|
/// The catalog was found and decoded; its entries are resolvable.
|
||||||
|
case loaded
|
||||||
|
/// No catalog resource exists in the bundle; every lookup returns its key.
|
||||||
|
case missing
|
||||||
|
/// The catalog resource exists but is not valid `.xcstrings` JSON; every lookup returns its key.
|
||||||
|
case undecodable
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@ import Foundation
|
|||||||
|
|
||||||
/// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale.
|
/// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale.
|
||||||
///
|
///
|
||||||
/// A server has no single "current" locale, so each lookup must name the locale to use. An instance
|
/// A server has no single "current" locale, so each lookup must name the locale to use. An instance is bound to the bundle whose catalog holds the strings,
|
||||||
/// is bound to the bundle whose catalog holds the strings, then invoked like a function to resolve a
|
/// then invoked like a function to resolve a key in a chosen locale.
|
||||||
/// key in a chosen locale.
|
|
||||||
public struct Localize: Sendable {
|
public struct Localize: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -12,6 +11,15 @@ public struct Localize: Sendable {
|
|||||||
/// The backend that resolves keys against the catalog.
|
/// The backend that resolves keys against the catalog.
|
||||||
private let resolver: any CatalogResolving
|
private let resolver: any CatalogResolving
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The outcome of reading the bundle's String Catalog.
|
||||||
|
///
|
||||||
|
/// Resolution degrades to returning raw keys rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
|
||||||
|
public var catalogState: CatalogState {
|
||||||
|
resolver.state
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a localizer backed by the String Catalog in the given bundle.
|
/// Creates a localizer backed by the String Catalog in the given bundle.
|
||||||
@@ -22,7 +30,7 @@ public struct Localize: Sendable {
|
|||||||
bundle: Bundle,
|
bundle: Bundle,
|
||||||
table: String = "Localizable"
|
table: String = "Localizable"
|
||||||
) {
|
) {
|
||||||
self.init(resolver: StringCatalog(
|
self.init(resolver: StringCatalog.cached(
|
||||||
bundle: bundle,
|
bundle: bundle,
|
||||||
table: table
|
table: table
|
||||||
))
|
))
|
||||||
@@ -46,8 +54,8 @@ public struct Localize: Sendable {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - key: the String Catalog key to look up.
|
/// - key: the String Catalog key to look up.
|
||||||
/// - locale: the locale to resolve the key in.
|
/// - locale: the locale to resolve the key in.
|
||||||
/// - Returns: the localized string for the locale, the source-language string when the locale has no
|
/// - Returns: the localized string for the locale, the source-language string when the locale has no entry, or the key itself when the catalog has no
|
||||||
/// entry, or the key itself when the catalog has no entry for it.
|
/// entry for it.
|
||||||
public func callAsFunction(
|
public func callAsFunction(
|
||||||
_ key: String,
|
_ key: String,
|
||||||
locale: Locale
|
locale: Locale
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import Foundation
|
|||||||
|
|
||||||
/// Negotiates the best supported language for a request from its `Accept-Language` header.
|
/// Negotiates the best supported language for a request from its `Accept-Language` header.
|
||||||
///
|
///
|
||||||
/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a
|
/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a function — through
|
||||||
/// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a
|
/// ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a supported language identifier, honouring the header's `q` weights as
|
||||||
/// supported language identifier, falling back to the default language.
|
/// RFC 9110 prescribes and falling back to the default language.
|
||||||
public struct Negotiate: Sendable {
|
public struct Negotiate: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -27,10 +27,10 @@ public struct Negotiate: Sendable {
|
|||||||
/// Picks the best supported language for the given `Accept-Language` header value.
|
/// Picks the best supported language for the given `Accept-Language` header value.
|
||||||
///
|
///
|
||||||
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
|
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
|
||||||
/// The header is split into its language tags (dropping any `q` weights), then each tag is matched
|
/// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight
|
||||||
/// against the supported languages in order of preference — first by an exact match, then by its
|
/// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the
|
||||||
/// primary language subtag, so `de-AT` resolves to a supported `de`. When the header is absent or
|
/// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the
|
||||||
/// matches nothing, the default language is returned.
|
/// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned.
|
||||||
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
|
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
|
||||||
/// - Returns: the identifier of the supported language to serve.
|
/// - Returns: the identifier of the supported language to serve.
|
||||||
public func callAsFunction(
|
public func callAsFunction(
|
||||||
@@ -40,16 +40,20 @@ public struct Negotiate: Sendable {
|
|||||||
return list.default
|
return list.default
|
||||||
}
|
}
|
||||||
|
|
||||||
let tags = tags(from: language)
|
let ranges = ranges(from: language)
|
||||||
|
|
||||||
guard !tags.isEmpty else {
|
guard !ranges.isEmpty else {
|
||||||
return list.default
|
return list.default
|
||||||
}
|
}
|
||||||
|
|
||||||
let supported = list.all
|
let supported = list.all
|
||||||
|
|
||||||
for tag in tags {
|
for range in ranges {
|
||||||
if let match = match(tag: tag, in: supported) {
|
if range.tag == .wildcard {
|
||||||
|
return list.default
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = match(range.tag, in: supported) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,39 +69,79 @@ private extension Negotiate {
|
|||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
/// Extracts the ordered language tags from an `Accept-Language` header value.
|
/// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference.
|
||||||
///
|
///
|
||||||
/// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and
|
/// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by descending weight, entries weighted 0 are dropped
|
||||||
/// blank entries are removed. The original order is kept, which mirrors descending preference
|
/// as "not acceptable", and equally weighted entries keep the header order.
|
||||||
/// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly.
|
|
||||||
/// - Parameter acceptLanguage: the raw `Accept-Language` header value.
|
/// - Parameter acceptLanguage: the raw `Accept-Language` header value.
|
||||||
/// - Returns: the ordered, weight-stripped language tags.
|
/// - Returns: the language ranges, most preferred first.
|
||||||
func tags(
|
func ranges(
|
||||||
from acceptLanguage: String
|
from acceptLanguage: String
|
||||||
) -> [String] {
|
) -> [LanguageRange] {
|
||||||
acceptLanguage
|
acceptLanguage
|
||||||
.split(separator: .Separator.comma)
|
.split(separator: .Separator.comma)
|
||||||
.map { entry in
|
.compactMap(range(from:))
|
||||||
entry
|
.filter { $0.quality > 0 }
|
||||||
.split(separator: .Separator.semicolon)
|
.enumerated()
|
||||||
.first
|
.sorted { lhs, rhs in
|
||||||
|
lhs.element.quality == rhs.element.quality
|
||||||
|
? lhs.offset < rhs.offset
|
||||||
|
: lhs.element.quality > rhs.element.quality
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a single `Accept-Language` header entry into its ``LanguageRange``.
|
||||||
|
///
|
||||||
|
/// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight.
|
||||||
|
/// A missing or malformed weight counts as 1, the highest preference, matching a tag sent without one.
|
||||||
|
/// - Parameter entry: a single comma-separated header entry.
|
||||||
|
/// - Returns: the entry's language range, or `nil` when it has no language tag.
|
||||||
|
func range(
|
||||||
|
from entry: Substring
|
||||||
|
) -> LanguageRange? {
|
||||||
|
let parts = entry.split(separator: .Separator.semicolon)
|
||||||
|
let tag = parts.first
|
||||||
.map(String.init)?
|
.map(String.init)?
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
?? .empty
|
?? .empty
|
||||||
|
|
||||||
|
guard !tag.isEmpty else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
|
let quality = parts
|
||||||
|
.dropFirst()
|
||||||
|
.compactMap { parameter -> Double? in
|
||||||
|
let parameter = parameter
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
guard parameter.hasPrefix(.Prefix.quality) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Double(parameter.dropFirst(String.Prefix.quality.count))
|
||||||
|
}
|
||||||
|
.first
|
||||||
|
?? 1
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
tag: tag,
|
||||||
|
quality: quality
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds the supported language that best matches a single `Accept-Language` tag.
|
/// Finds the supported language that best matches a single `Accept-Language` tag.
|
||||||
///
|
///
|
||||||
/// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the
|
/// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the supported languages' primary subtags, so a
|
||||||
/// supported languages' primary subtags, so a regional tag such as `de-AT` resolves to `de`.
|
/// regional tag such as `de-AT` resolves to `de`.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - tag: a single language tag from the header.
|
/// - tag: a single language tag from the header.
|
||||||
/// - supported: the supported language identifiers.
|
/// - supported: the supported language identifiers.
|
||||||
/// - Returns: the matching supported language, or `nil` when the tag matches none.
|
/// - Returns: the matching supported language, or `nil` when the tag matches none.
|
||||||
func match(
|
func match(
|
||||||
tag: String,
|
_ tag: String,
|
||||||
in supported: [String]
|
in supported: [String]
|
||||||
) -> String? {
|
) -> String? {
|
||||||
let tag = tag.lowercased()
|
let tag = tag.lowercased()
|
||||||
@@ -117,10 +161,32 @@ private extension Negotiate {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Character+Extensions
|
||||||
|
|
||||||
|
private extension Character {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
enum Separator {
|
||||||
|
static let comma: Character = ","
|
||||||
|
static let dash: Character = "-"
|
||||||
|
static let semicolon: Character = ";"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - String+Extensions
|
// MARK: - String+Extensions
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
static let empty: String = ""
|
static let empty: String = ""
|
||||||
|
static let wildcard: String = "*"
|
||||||
|
|
||||||
|
enum Prefix {
|
||||||
|
static let quality = "q="
|
||||||
|
}
|
||||||
|
|
||||||
/// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`).
|
/// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`).
|
||||||
var primarySubtag: String {
|
var primarySubtag: String {
|
||||||
@@ -130,13 +196,3 @@ private extension String {
|
|||||||
?? self
|
?? self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
private extension Character {
|
|
||||||
enum Separator {
|
|
||||||
static let comma: Character = ","
|
|
||||||
static let dash: Character = "-"
|
|
||||||
static let semicolon: Character = ";"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,46 +8,45 @@ public struct LanguageList: Sendable {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The language served when none of the supported languages match a request.
|
|
||||||
///
|
|
||||||
/// Should match the catalog bundle's development localization.
|
|
||||||
public let `default`: String
|
|
||||||
|
|
||||||
/// The backend that reports the available languages.
|
/// The backend that reports the available languages.
|
||||||
private let resolver: any CatalogResolving
|
private let resolver: any CatalogResolving
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a language list backed by the given bundle.
|
/// Creates a language list backed by the given bundle.
|
||||||
/// - Parameters:
|
/// - Parameter bundle: the bundle whose String Catalog defines the available languages.
|
||||||
/// - bundle: the bundle whose String Catalog defines the available languages.
|
|
||||||
/// - default: the language served when none of the supported languages match. Defaults to `"en"`.
|
|
||||||
public init(
|
public init(
|
||||||
bundle: Bundle,
|
bundle: Bundle
|
||||||
`default`: String = "en"
|
|
||||||
) {
|
) {
|
||||||
self.init(
|
self.init(resolver: StringCatalog.cached(bundle: bundle))
|
||||||
resolver: StringCatalog(bundle: bundle),
|
|
||||||
default: `default`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a language list backed by the given resolver.
|
/// Creates a language list backed by the given resolver.
|
||||||
///
|
///
|
||||||
/// The seam for tests and alternative backends; the public API derives languages from a bundled catalog.
|
/// The seam for tests and alternative backends; the public API derives languages from a bundled catalog.
|
||||||
/// - Parameters:
|
/// - Parameter resolver: the backend that reports the available languages.
|
||||||
/// - resolver: the backend that reports the available languages.
|
|
||||||
/// - default: the language served when none of the supported languages match.
|
|
||||||
init(
|
init(
|
||||||
resolver: any CatalogResolving,
|
resolver: any CatalogResolving
|
||||||
`default`: String = "en"
|
|
||||||
) {
|
) {
|
||||||
self.resolver = resolver
|
self.resolver = resolver
|
||||||
self.`default` = `default`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The outcome of reading the bundle's String Catalog.
|
||||||
|
///
|
||||||
|
/// The list degrades to the default language rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
|
||||||
|
public var catalogState: CatalogState {
|
||||||
|
resolver.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language served when none of the supported languages match a request.
|
||||||
|
///
|
||||||
|
/// Always the catalog's source (development) language, so the default cannot drift from the bundle the list is bound to.
|
||||||
|
public var `default`: String {
|
||||||
|
resolver.sourceLanguage
|
||||||
|
}
|
||||||
|
|
||||||
/// Every language the bundle's String Catalog provides a localization for.
|
/// Every language the bundle's String Catalog provides a localization for.
|
||||||
///
|
///
|
||||||
/// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language.
|
/// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import Testing
|
|||||||
|
|
||||||
@testable import Localization
|
@testable import Localization
|
||||||
|
|
||||||
@Suite("Localize method")
|
@Suite(
|
||||||
|
"Localize method",
|
||||||
|
.tags(.method)
|
||||||
|
)
|
||||||
struct LocalizeTests {
|
struct LocalizeTests {
|
||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
@@ -32,6 +35,36 @@ struct LocalizeTests {
|
|||||||
#expect(text == "Hallo")
|
#expect(text == "Hallo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `resolves a key in a regional locale`() {
|
||||||
|
let text = localize(
|
||||||
|
"test.greeting",
|
||||||
|
locale: Locale(identifier: "pt-BR"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text == "Olá")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the primary language for an unmatched regional locale`() {
|
||||||
|
let text = localize(
|
||||||
|
"test.greeting",
|
||||||
|
locale: Locale(identifier: "de-AT"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text == "Hallo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the source language for an unsupported locale`() {
|
||||||
|
let text = localize(
|
||||||
|
"test.greeting",
|
||||||
|
locale: Locale(identifier: "fr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text == "Hello")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `falls back to the key for an unknown entry`() {
|
func `falls back to the key for an unknown entry`() {
|
||||||
let text = localize(
|
let text = localize(
|
||||||
@@ -42,4 +75,60 @@ struct LocalizeTests {
|
|||||||
#expect(text == "unknown.key")
|
#expect(text == "unknown.key")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the key for a missing catalog`() {
|
||||||
|
let localize = Localize(bundle: .main)
|
||||||
|
|
||||||
|
let text = localize(
|
||||||
|
"test.greeting",
|
||||||
|
locale: Locale(identifier: "en")
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text == "test.greeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Properties tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `reports a loaded catalog`() {
|
||||||
|
#expect(localize.catalogState == .loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `reports a missing catalog`() {
|
||||||
|
let localize = Localize(bundle: .main)
|
||||||
|
|
||||||
|
#expect(localize.catalogState == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed catalog cannot ship as a test resource — Xcode generates string symbols for every
|
||||||
|
// bundled `.xcstrings` and fails the build on invalid JSON — so one is staged in a temporary
|
||||||
|
// directory bundle instead.
|
||||||
|
@Test
|
||||||
|
func `reports an undecodable catalog`() throws {
|
||||||
|
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
.appendingPathComponent(
|
||||||
|
"UndecodableCatalog-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
try Data("not a catalog".utf8).write(
|
||||||
|
to: directory.appendingPathComponent("Localizable.xcstrings")
|
||||||
|
)
|
||||||
|
|
||||||
|
let bundle = try #require(Bundle(url: directory))
|
||||||
|
let localize = Localize(bundle: bundle)
|
||||||
|
|
||||||
|
#expect(localize.catalogState == .undecodable)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import Testing
|
|||||||
|
|
||||||
@testable import Localization
|
@testable import Localization
|
||||||
|
|
||||||
@Suite("Negotiate method")
|
@Suite(
|
||||||
|
"Negotiate method",
|
||||||
|
.tags(.method)
|
||||||
|
)
|
||||||
struct NegotiateTests {
|
struct NegotiateTests {
|
||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
@@ -26,6 +29,20 @@ struct NegotiateTests {
|
|||||||
#expect(language == "de")
|
#expect(language == "de")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `matches a regional catalog language exactly`() {
|
||||||
|
let language = negotiate(acceptLanguage: "pt-BR")
|
||||||
|
|
||||||
|
#expect(language == "pt-BR")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `matches a regional catalog language by its primary subtag`() {
|
||||||
|
let language = negotiate(acceptLanguage: "pt")
|
||||||
|
|
||||||
|
#expect(language == "pt-BR")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `respects the header order of preference`() {
|
func `respects the header order of preference`() {
|
||||||
let language = negotiate(acceptLanguage: "de, en")
|
let language = negotiate(acceptLanguage: "de, en")
|
||||||
@@ -34,12 +51,47 @@ struct NegotiateTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `strips quality weights from the language tags`() {
|
func `orders the language tags by descending quality weight`() {
|
||||||
let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9")
|
let language = negotiate(acceptLanguage: "en;q=0.5, de;q=0.9")
|
||||||
|
|
||||||
#expect(language == "de")
|
#expect(language == "de")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `treats a tag without a weight as the highest preference`() {
|
||||||
|
let language = negotiate(acceptLanguage: "en;q=0.9, de")
|
||||||
|
|
||||||
|
#expect(language == "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `treats a tag with a malformed weight as the highest preference`() {
|
||||||
|
let language = negotiate(acceptLanguage: "en;q=0.9, de;q=broken")
|
||||||
|
|
||||||
|
#expect(language == "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `drops language tags weighted as not acceptable`() {
|
||||||
|
let language = negotiate(acceptLanguage: "de;q=0, en;q=0.8")
|
||||||
|
|
||||||
|
#expect(language == "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `falls back to the default when every tag is weighted as not acceptable`() {
|
||||||
|
let language = negotiate(acceptLanguage: "de;q=0, fr;q=0")
|
||||||
|
|
||||||
|
#expect(language == "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the default language for a wildcard`() {
|
||||||
|
let language = negotiate(acceptLanguage: "fr;q=0.9, *;q=0.5")
|
||||||
|
|
||||||
|
#expect(language == "en")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `trims whitespace around the language tags`() {
|
func `trims whitespace around the language tags`() {
|
||||||
let language = negotiate(acceptLanguage: " de , en ")
|
let language = negotiate(acceptLanguage: " de , en ")
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import Testing
|
|||||||
|
|
||||||
@testable import Localization
|
@testable import Localization
|
||||||
|
|
||||||
@Suite("LanguageList type")
|
@Suite(
|
||||||
|
"LanguageList type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
struct LanguageListTests {
|
struct LanguageListTests {
|
||||||
|
|
||||||
// MARK: Properties tests
|
// MARK: Properties tests
|
||||||
@@ -20,16 +23,39 @@ struct LanguageListTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `uses the provided default language`() {
|
func `follows the catalog's source language`() {
|
||||||
let list = LanguageList(
|
let list = LanguageList(
|
||||||
bundle: .module,
|
resolver: StubCatalog(
|
||||||
default: "de"
|
sourceLanguage: "de",
|
||||||
|
languages: ["de", "en"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(list.default == "de")
|
#expect(list.default == "de")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("catalogState")
|
||||||
|
struct CatalogState {
|
||||||
|
@Test
|
||||||
|
func `reports a loaded catalog`() {
|
||||||
|
let list = LanguageList(
|
||||||
|
bundle: .module
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(list.catalogState == .loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `reports a missing catalog`() {
|
||||||
|
let list = LanguageList(
|
||||||
|
bundle: .main
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(list.catalogState == .missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("all")
|
@Suite("all")
|
||||||
struct All {
|
struct All {
|
||||||
@Test
|
@Test
|
||||||
@@ -45,20 +71,34 @@ struct LanguageListTests {
|
|||||||
@Test
|
@Test
|
||||||
func `excludes the base localization`() {
|
func `excludes the base localization`() {
|
||||||
let list = LanguageList(
|
let list = LanguageList(
|
||||||
bundle: .module
|
resolver: StubCatalog(
|
||||||
|
sourceLanguage: "en",
|
||||||
|
languages: ["Base", "de", "en"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(!list.all.contains("Base"))
|
#expect(list.all == ["de", "en"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `lists each language only once`() {
|
func `sorts the languages`() {
|
||||||
let list = LanguageList(
|
let list = LanguageList(
|
||||||
bundle: .module
|
resolver: StubCatalog(
|
||||||
|
sourceLanguage: "en",
|
||||||
|
languages: ["fr", "en", "de"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
let all = list.all
|
|
||||||
|
|
||||||
#expect(all.count == Set(all).count)
|
#expect(list.all == ["de", "en", "fr"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `degrades to the default language for a missing catalog`() {
|
||||||
|
let list = LanguageList(
|
||||||
|
bundle: .main
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(list.all == ["en"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Hello"
|
"value" : "Hello"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Olá"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
/// Tests exercising a method of the Localization package.
|
||||||
|
@Tag static var method: Tag
|
||||||
|
/// Tests exercising a type of the Localization package.
|
||||||
|
@Tag static var type: Tag
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@testable import Localization
|
||||||
|
|
||||||
|
/// A ``CatalogResolving`` backend with fixed languages, for exercising types apart from a bundled catalog.
|
||||||
|
struct StubCatalog: CatalogResolving {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let sourceLanguage: String
|
||||||
|
let languages: Set<String>
|
||||||
|
var state: CatalogState = .loaded
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
func string(
|
||||||
|
for key: String,
|
||||||
|
in locale: Locale
|
||||||
|
) -> String {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -32,6 +32,14 @@ let package = Package(
|
|||||||
url: "https://github.com/vapor/sql-kit.git",
|
url: "https://github.com/vapor/sql-kit.git",
|
||||||
from: "3.36.0"
|
from: "3.36.0"
|
||||||
),
|
),
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/vapor/mysql-nio.git",
|
||||||
|
from: "1.7.0"
|
||||||
|
),
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/apple/swift-nio.git",
|
||||||
|
from: "2.65.0"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -59,7 +67,19 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "PersistenceTests",
|
name: "PersistenceTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.byName(name: "Persistence")
|
.byName(name: "Persistence"),
|
||||||
|
.product(
|
||||||
|
name: "MySQLNIO",
|
||||||
|
package: "mysql-nio"
|
||||||
|
),
|
||||||
|
.product(
|
||||||
|
name: "NIOCore",
|
||||||
|
package: "swift-nio"
|
||||||
|
),
|
||||||
|
.product(
|
||||||
|
name: "NIOPosix",
|
||||||
|
package: "swift-nio"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
path: "Tests"
|
path: "Tests"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Persistence
|
||||||
|
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a MySQL/MariaDB backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The package provides, grouped by role:
|
||||||
|
| Role | Types |
|
||||||
|
| --- | --- |
|
||||||
|
| Backend selection | `Driver` (`mysql` or `inMemory`), `Configuration` (the MySQL/MariaDB connection parameters), `TLS` (the connection's TLS posture) |
|
||||||
|
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||||
|
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||||
|
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` |
|
||||||
|
| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. See the Website service's `ConfigReader+Properties` for the mapping.
|
||||||
|
- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
|
||||||
|
- **Migrations are declared once, and append-only.** `PrepareDB` is the single place migrations are registered, in the order they must run; alter the schema by adding a new migration, never by editing one that has already run. Registering does not apply them — the in-memory backend is migrated on startup, while a shared MySQL/MariaDB database is migrated out of band (the executable's migrate-and-exit mode), so multiple booting instances never race.
|
||||||
|
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types; repositories map them to `Sendable` value-type snapshots (e.g. `Example`) before returning, and the models themselves stay internal to the package.
|
||||||
|
- **Readiness never throws.** `Probe` runs a `SELECT 1` — the cheapest statement both backends understand, independent of any schema — and maps every failure to `false`, so callers translate it straight into a readiness response.
|
||||||
|
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop so every query reaches the same in-memory database, rather than each pooled connection getting its own private one.
|
||||||
|
- **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
|
||||||
|
> **Note:** the `prefer` TLS posture is enforced by the driver itself: a supplied TLS configuration upgrades the connection only when the server advertises TLS, and continues in plaintext otherwise (pinned by a test against a fake server that offers no TLS). `require` currently maps to the same configuration and therefore behaves like `prefer` — the refusal when the server offers no TLS is not yet enforced.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
Sources are split by visibility, then by kind, one type per file:
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── Public/
|
||||||
|
│ ├── Enumerations/ Driver, TLS
|
||||||
|
│ ├── Methods/ Service, PrepareDB, Probe
|
||||||
|
│ └── Types/ Configuration
|
||||||
|
└── Internal/
|
||||||
|
├── Migrations/ CreateExampleRecord
|
||||||
|
├── Models/ ExampleRecord
|
||||||
|
└── Repositories/ ExampleRepository (returning the Example snapshot)
|
||||||
|
Tests/
|
||||||
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
|
||||||
|
plaintext-only fake MySQL server, and the suite Tag constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
The suite runs against the in-memory backend by default, so `swift test` needs no database. The MySQL/MariaDB integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`, `MYSQL_TEST_NAME`, `MYSQL_TEST_USERNAME`, and `MYSQL_TEST_PASSWORD`); it reverts its migrations afterwards, so the shared database is left as it was found:
|
||||||
|
```sh
|
||||||
|
# in-memory only
|
||||||
|
swift test
|
||||||
|
# or
|
||||||
|
# with the local MariaDB up (make db-mount):
|
||||||
|
MYSQL_TEST_HOST=127.0.0.1 swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
Outside the application's service group, a built `Fluent` service must be shut down explicitly — even on failure — or its connection pool asserts on `deinit`; the suites' `do`/`catch` pattern around `fluent.shutdown()` is the shape to follow.
|
||||||
|
|
||||||
|
Every suite carries a tag naming the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
|
- macOS 15, matching the sibling `Infrastructure` and `Localization` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
|
- Package dependencies: `hummingbird-fluent`, `fluent-mysql-driver`, `fluent-sqlite-driver`, and `sql-kit`; the test target additionally depends on `mysql-nio` and `swift-nio` for the TLS fallback test.
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
/// The persistence backend the service runs against.
|
/// The persistence backend the service runs against.
|
||||||
///
|
///
|
||||||
/// The executable picks a driver at startup and hands it to ``Service``, which registers the
|
/// The executable picks a driver at startup and hands it to ``Service``, which registers the matching database as the default one. Repositories resolve
|
||||||
/// matching database as the default one. Repositories resolve that default and stay agnostic
|
/// that default and stay agnostic of which backend is in use.
|
||||||
/// of which backend is in use.
|
|
||||||
public enum Driver: Sendable {
|
public enum Driver: Sendable {
|
||||||
|
|
||||||
/// A MySQL/MariaDB server, reached with the given connection parameters.
|
/// A MySQL/MariaDB server, reached with the given connection parameters.
|
||||||
///
|
///
|
||||||
/// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the
|
/// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the connection is opened with.
|
||||||
/// connection is opened with.
|
|
||||||
case mysql(Configuration)
|
case mysql(Configuration)
|
||||||
|
|
||||||
/// An ephemeral, in-process SQLite database held entirely in memory.
|
/// An ephemeral, in-process SQLite database held entirely in memory.
|
||||||
///
|
///
|
||||||
/// Nothing is written to disk, and all data is lost when the service stops — intended for
|
/// Nothing is written to disk, and all data is lost when the service stops — intended for local development and tests.
|
||||||
/// local development and tests.
|
|
||||||
case inMemory
|
case inMemory
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import NIOSSL
|
|||||||
|
|
||||||
/// The TLS posture used when connecting to the database.
|
/// The TLS posture used when connecting to the database.
|
||||||
///
|
///
|
||||||
/// The executable derives a posture from its `database.tls` configuration and passes it along as
|
/// The executable derives a posture from its `database.tls` configuration and passes it along as part of ``Configuration``; the MySQL driver
|
||||||
/// part of ``Configuration``; the MySQL driver receives the resulting `TLSConfiguration` through
|
/// receives the resulting `TLSConfiguration` through ``tlsConfiguration``.
|
||||||
/// ``tlsConfiguration``.
|
|
||||||
public enum TLS: Sendable {
|
public enum TLS: Sendable {
|
||||||
|
|
||||||
/// Connect without TLS, in plaintext.
|
/// Connect without TLS, in plaintext.
|
||||||
@@ -14,6 +13,9 @@ public enum TLS: Sendable {
|
|||||||
case prefer
|
case prefer
|
||||||
|
|
||||||
/// Connect only over TLS, refusing the connection when the server offers none.
|
/// Connect only over TLS, refusing the connection when the server offers none.
|
||||||
|
///
|
||||||
|
/// - Important: the refusal is not yet enforced — until it is, `require` behaves like ``prefer`` and silently falls back to plaintext when the
|
||||||
|
/// server offers no TLS.
|
||||||
case require
|
case require
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -24,18 +26,15 @@ extension TLS {
|
|||||||
|
|
||||||
/// The NIO TLS configuration passed to the MySQL driver for this posture.
|
/// The NIO TLS configuration passed to the MySQL driver for this posture.
|
||||||
///
|
///
|
||||||
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for
|
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``.
|
||||||
/// ``prefer`` and ``require``.
|
|
||||||
///
|
///
|
||||||
/// - Note: `prefer` and `require` currently map to the same client configuration — both enable TLS.
|
/// - Note: the driver gives a supplied configuration ``prefer`` semantics natively — it upgrades to TLS only when the server advertises support,
|
||||||
/// The distinction (fall back to plaintext vs. fail when the server offers no TLS) is not yet
|
/// and continues in plaintext otherwise — so `prefer` is fully enforced. `require` maps to the same configuration and therefore currently
|
||||||
/// enforced here; tighten this mapping if that guarantee becomes required.
|
/// behaves like ``prefer``: the refusal when the server offers no TLS is not yet enforced.
|
||||||
var tlsConfiguration: TLSConfiguration? {
|
var tlsConfiguration: TLSConfiguration? {
|
||||||
switch self {
|
switch self {
|
||||||
case .off:
|
case .off: nil
|
||||||
return nil
|
default: .makeClientConfiguration()
|
||||||
case .prefer, .require:
|
|
||||||
return .makeClientConfiguration()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import HummingbirdFluent
|
|||||||
|
|
||||||
/// A registrar declaring every migration against a `Fluent` service.
|
/// A registrar declaring every migration against a `Fluent` service.
|
||||||
///
|
///
|
||||||
/// Built once around the application's `Fluent` service and called as a function — `await migrate()` —
|
/// Built once around the application's `Fluent` service and called as a function — `await migrate()` — during startup, before the migrations are
|
||||||
/// during startup, before the migrations are applied.
|
/// applied.
|
||||||
public struct PrepareDB {
|
public struct PrepareDB: Sendable {
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ public struct PrepareDB {
|
|||||||
|
|
||||||
/// Registers every migration against the `Fluent` service, in order.
|
/// Registers every migration against the `Fluent` service, in order.
|
||||||
///
|
///
|
||||||
/// This is the single place migrations are declared: add each new migration here, in the order it must
|
/// This is the single place migrations are declared: add each new migration here, in the order it must run (migrations are applied in registration order
|
||||||
/// run (migrations are applied in registration order and are append-only). Registering does not apply
|
/// and are append-only). Registering does not apply them — the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do
|
||||||
/// them — the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do that.
|
/// that.
|
||||||
public func callAsFunction(
|
public func callAsFunction(
|
||||||
for fluent: Fluent
|
for fluent: Fluent
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import SQLKit
|
|||||||
|
|
||||||
/// A readiness probe reporting whether the database behind a `Fluent` service is reachable.
|
/// A readiness probe reporting whether the database behind a `Fluent` service is reachable.
|
||||||
///
|
///
|
||||||
/// Built once around the application's `Fluent` service and called as a function whenever a fresh
|
/// Built once around the application's `Fluent` service and called as a function whenever a fresh answer is needed — typically from a readiness endpoint:
|
||||||
/// answer is needed — typically from a readiness endpoint: `let ready = await probe()`.
|
/// `let ready = await probe()`.
|
||||||
public struct Probe: Sendable {
|
public struct Probe: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -26,11 +26,10 @@ public struct Probe: Sendable {
|
|||||||
|
|
||||||
/// Reports whether the database behind the `Fluent` service is reachable.
|
/// Reports whether the database behind the `Fluent` service is reachable.
|
||||||
///
|
///
|
||||||
/// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the MySQL/MariaDB
|
/// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the MySQL/MariaDB and SQLite backends understand — so
|
||||||
/// and SQLite backends understand — so a readiness check does not depend on any particular schema or model.
|
/// a readiness check does not depend on any particular schema or model. Any failure (connection refused, authentication error, pool exhausted) is
|
||||||
/// Any failure (connection refused, authentication error, pool exhausted) is reported as not reachable
|
/// reported as not reachable rather than thrown, so callers can map it straight onto a readiness response. A default database that is not an SQL
|
||||||
/// rather than thrown, so callers can map it straight onto a readiness response. A default database that
|
/// database is likewise reported as not reachable.
|
||||||
/// is not an SQL database is likewise reported as not reachable.
|
|
||||||
/// - Returns: `true` when the database answers the probe, `false` otherwise.
|
/// - Returns: `true` when the database answers the probe, `false` otherwise.
|
||||||
public func callAsFunction() async -> Bool {
|
public func callAsFunction() async -> Bool {
|
||||||
guard let database = fluent.db() as? any SQLDatabase else {
|
guard let database = fluent.db() as? any SQLDatabase else {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import Logging
|
|||||||
|
|
||||||
/// A factory building the `Fluent` service the application persists through.
|
/// A factory building the `Fluent` service the application persists through.
|
||||||
///
|
///
|
||||||
/// Built once around the driver the executable picks at startup and called as a function to produce
|
/// Built once around the driver the executable picks at startup and called as a function to produce the configured service: `let fluent = service()`.
|
||||||
/// the configured service: `let fluent = service()`.
|
|
||||||
public struct Service: Sendable {
|
public struct Service: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -35,10 +34,9 @@ public struct Service: Sendable {
|
|||||||
|
|
||||||
/// Builds a `Fluent` service configured for the driver.
|
/// Builds a `Fluent` service configured for the driver.
|
||||||
///
|
///
|
||||||
/// The selected backend is registered as the *default* database, so repositories resolve it with a plain
|
/// The selected backend is registered as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which
|
||||||
/// `fluent.db()` and stay agnostic of which driver is in use. The returned service is not yet running; add
|
/// driver is in use. The returned service is not yet running; add it to the application's service group (`app.addServices(_:)`) so it starts and shuts
|
||||||
/// it to the application's service group (`app.addServices(_:)`) so it starts and shuts its connection pool
|
/// its connection pool down alongside the server.
|
||||||
/// down alongside the server.
|
|
||||||
/// - Returns: the configured `Fluent` service, ready to be added to the service group.
|
/// - Returns: the configured `Fluent` service, ready to be added to the service group.
|
||||||
public func callAsFunction() -> Fluent {
|
public func callAsFunction() -> Fluent {
|
||||||
let fluent = Fluent(
|
let fluent = Fluent(
|
||||||
@@ -63,8 +61,8 @@ public struct Service: Sendable {
|
|||||||
isDefault: true
|
isDefault: true
|
||||||
)
|
)
|
||||||
case .inMemory:
|
case .inMemory:
|
||||||
// A single connection keeps every query pointed at the same in-memory store,
|
// A single connection keeps every query pointed at the same in-memory store, rather than each pooled
|
||||||
// rather than each pooled connection getting its own private database.
|
// connection getting its own private database.
|
||||||
fluent.databases.use(
|
fluent.databases.use(
|
||||||
.sqlite(.memory, maxConnectionsPerEventLoop: 1),
|
.sqlite(.memory, maxConnectionsPerEventLoop: 1),
|
||||||
as: .sqlite,
|
as: .sqlite,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/// The connection parameters for the MySQL/MariaDB backend.
|
/// The connection parameters for the MySQL/MariaDB backend.
|
||||||
///
|
///
|
||||||
/// The executable builds this from its `database.*` configuration; the package itself reads no
|
/// The executable builds this from its `database.*` configuration; the package itself reads no configuration, so these values arrive as plain data.
|
||||||
/// configuration, so these values arrive as plain data.
|
|
||||||
public struct Configuration: Sendable {
|
public struct Configuration: Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
import Logging
|
||||||
|
import MySQLNIO
|
||||||
|
import NIOCore
|
||||||
|
import NIOPosix
|
||||||
import NIOSSL
|
import NIOSSL
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import Persistence
|
@testable import Persistence
|
||||||
|
|
||||||
@Suite("TLS enumeration")
|
@Suite(
|
||||||
|
"TLS enumeration",
|
||||||
|
.tags(.enumeration)
|
||||||
|
)
|
||||||
struct TLSTests {
|
struct TLSTests {
|
||||||
|
|
||||||
// MARK: Properties tests
|
// MARK: Properties tests
|
||||||
@@ -25,4 +32,27 @@ struct TLSTests {
|
|||||||
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
|
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `prefer falls back to plaintext when the server offers no TLS`() async throws {
|
||||||
|
// The fake server never advertises `CLIENT_SSL`, so this connection can only succeed by downgrading to
|
||||||
|
// plaintext — pinning the driver behavior the `prefer` posture relies on.
|
||||||
|
let server = try await PlaintextMySQLServer.start()
|
||||||
|
let tlsConfiguration = try #require(TLS.prefer.tlsConfiguration)
|
||||||
|
let connection = try await MySQLConnection.connect(
|
||||||
|
to: .init(ipAddress: "127.0.0.1", port: server.port),
|
||||||
|
username: "loud",
|
||||||
|
database: "loud",
|
||||||
|
tlsConfiguration: tlsConfiguration,
|
||||||
|
logger: Logger(label: "test"),
|
||||||
|
on: MultiThreadedEventLoopGroup.singleton.any()
|
||||||
|
).get()
|
||||||
|
|
||||||
|
let isConnected = !connection.isClosed
|
||||||
|
|
||||||
|
try await connection.close().get()
|
||||||
|
try await server.stop()
|
||||||
|
|
||||||
|
#expect(isConnected)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import FluentKit
|
import FluentKit
|
||||||
import HummingbirdFluent
|
import HummingbirdFluent
|
||||||
import Logging
|
import Logging
|
||||||
import NIOCore
|
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import Persistence
|
@testable import Persistence
|
||||||
|
|
||||||
@Suite("Probe method")
|
@Suite(
|
||||||
|
"Probe method",
|
||||||
|
.tags(.method)
|
||||||
|
)
|
||||||
struct ProbeTests {
|
struct ProbeTests {
|
||||||
|
|
||||||
// MARK: Methods tests
|
// MARK: Methods tests
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import Testing
|
|||||||
|
|
||||||
@testable import Persistence
|
@testable import Persistence
|
||||||
|
|
||||||
@Suite("Service method")
|
@Suite(
|
||||||
|
"Service method",
|
||||||
|
.tags(.method)
|
||||||
|
)
|
||||||
struct ServiceTests {
|
struct ServiceTests {
|
||||||
|
|
||||||
// MARK: Methods tests
|
// MARK: Methods tests
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
/// Tests exercising an enumeration of the Persistence package.
|
||||||
|
@Tag static var enumeration: Tag
|
||||||
|
/// Tests exercising a method of the Persistence package.
|
||||||
|
@Tag static var method: Tag
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import NIOCore
|
||||||
|
import NIOPosix
|
||||||
|
|
||||||
|
/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake.
|
||||||
|
///
|
||||||
|
/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and
|
||||||
|
/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only
|
||||||
|
/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
|
||||||
|
/// downgrades to plaintext rather than refusing the connection.
|
||||||
|
final class PlaintextMySQLServer {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The port the server listens on, assigned by the system at bind time.
|
||||||
|
let port: Int
|
||||||
|
|
||||||
|
/// The listening channel the server accepts connections through.
|
||||||
|
private let channel: Channel
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
private init(
|
||||||
|
channel: Channel,
|
||||||
|
port: Int
|
||||||
|
) {
|
||||||
|
self.channel = channel
|
||||||
|
self.port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
/// Starts a server on the loopback interface, on a system-assigned port.
|
||||||
|
/// - Returns: the running server, ready to be connected to at ``port``.
|
||||||
|
static func start() async throws -> PlaintextMySQLServer {
|
||||||
|
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
|
||||||
|
.childChannelInitializer { channel in
|
||||||
|
channel.eventLoop.makeCompletedFuture {
|
||||||
|
try channel.pipeline.syncOperations.addHandler(Handler())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bind(host: "127.0.0.1", port: 0)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
guard let port = channel.localAddress?.port else {
|
||||||
|
throw ChannelError.unknownLocalAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
channel: channel,
|
||||||
|
port: port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the server, closing its listening channel.
|
||||||
|
func stop() async throws {
|
||||||
|
try await channel.close().get()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handlers
|
||||||
|
|
||||||
|
private extension PlaintextMySQLServer {
|
||||||
|
|
||||||
|
/// Greets a freshly accepted connection, accepts whatever authentication response arrives,
|
||||||
|
/// and closes on anything after that (e.g. a `COM_QUIT`).
|
||||||
|
final class Handler: ChannelInboundHandler {
|
||||||
|
|
||||||
|
// MARK: Type aliases
|
||||||
|
|
||||||
|
typealias InboundIn = ByteBuffer
|
||||||
|
typealias OutboundOut = ByteBuffer
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// Whether the client's handshake response has already been answered with an OK packet.
|
||||||
|
private var didAuthenticate = false
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func channelActive(context: ChannelHandlerContext) {
|
||||||
|
context.writeAndFlush(
|
||||||
|
wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)),
|
||||||
|
promise: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelRead(
|
||||||
|
context: ChannelHandlerContext,
|
||||||
|
data: NIOAny
|
||||||
|
) {
|
||||||
|
guard didAuthenticate else {
|
||||||
|
didAuthenticate = true
|
||||||
|
|
||||||
|
context.writeAndFlush(
|
||||||
|
wrapOutboundOut(Self.ok(allocator: context.channel.allocator)),
|
||||||
|
promise: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.close(promise: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
/// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet.
|
||||||
|
///
|
||||||
|
/// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`,
|
||||||
|
/// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`,
|
||||||
|
/// so the client cannot upgrade the connection to TLS.
|
||||||
|
private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||||
|
var payload = allocator.buffer(capacity: 80)
|
||||||
|
|
||||||
|
payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version
|
||||||
|
payload.writeNullTerminatedString("8.0.0") // server version
|
||||||
|
payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id
|
||||||
|
payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1
|
||||||
|
payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler
|
||||||
|
payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION
|
||||||
|
payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8)
|
||||||
|
payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit)
|
||||||
|
payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH
|
||||||
|
payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length
|
||||||
|
payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved
|
||||||
|
payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2
|
||||||
|
payload.writeNullTerminatedString("mysql_native_password") // auth plugin name
|
||||||
|
|
||||||
|
return framed(payload, sequence: 0, allocator: allocator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare OK packet, framed as the reply to the client's handshake response.
|
||||||
|
private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||||
|
var payload = allocator.buffer(capacity: 8)
|
||||||
|
|
||||||
|
payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings
|
||||||
|
|
||||||
|
return framed(payload, sequence: 2, allocator: allocator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte.
|
||||||
|
private static func framed(
|
||||||
|
_ payload: ByteBuffer,
|
||||||
|
sequence: UInt8,
|
||||||
|
allocator: ByteBufferAllocator
|
||||||
|
) -> ByteBuffer {
|
||||||
|
var packet = allocator.buffer(capacity: payload.readableBytes + 4)
|
||||||
|
var payload = payload
|
||||||
|
|
||||||
|
packet.writeInteger(UInt8(payload.readableBytes & 0xff))
|
||||||
|
packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff))
|
||||||
|
packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff))
|
||||||
|
packet.writeInteger(sequence)
|
||||||
|
packet.writeBuffer(&payload)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// swift-tools-version: 6.3
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Utility",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "Utility",
|
||||||
|
targets: [
|
||||||
|
"Utility"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Utility",
|
||||||
|
path: "Sources",
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "UtilityTests",
|
||||||
|
dependencies: [
|
||||||
|
.byName(name: "Utility")
|
||||||
|
],
|
||||||
|
path: "Tests"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Utility
|
||||||
|
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The package provides, grouped by role:
|
||||||
|
| Role | Types |
|
||||||
|
| --- | --- |
|
||||||
|
| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird).
|
||||||
|
- **Method structs.** Helpers hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
Sources are split by visibility, then by kind, one type per file:
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
└── Public/
|
||||||
|
└── Methods/ NormalizeEmail
|
||||||
|
Tests/
|
||||||
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
└── Utils/ the suite Tag constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
Every suite carries a tag naming the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||||
|
- macOS 15, matching the sibling `Infrastructure`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
|
- No package dependencies — Foundation only.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A validator reducing a submitted email address to its canonical form.
|
||||||
|
///
|
||||||
|
/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can
|
||||||
|
/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes
|
||||||
|
/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address.
|
||||||
|
public struct NormalizeEmail: Sendable {
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates an email normalization method.
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
/// Validates and normalizes a submitted email address.
|
||||||
|
/// - Parameter email: the submitted email address, if any.
|
||||||
|
/// - Returns: the normalized address, or `nil` when the submission is missing or invalid.
|
||||||
|
public func callAsFunction(
|
||||||
|
_ email: String?
|
||||||
|
) -> String? {
|
||||||
|
guard
|
||||||
|
let email = email?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased(),
|
||||||
|
email.utf8.count <= Constant.Length.maxEmail,
|
||||||
|
!email.unicodeScalars.contains(where: {
|
||||||
|
$0.properties.generalCategory == .control
|
||||||
|
}),
|
||||||
|
email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private enum Constant {
|
||||||
|
/// A namespace for the length constants.
|
||||||
|
enum Length {
|
||||||
|
/// The longest an email address can be, in bytes, per RFC 5321's path limit.
|
||||||
|
static let maxEmail = 254
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Utility
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"NormalizeEmail method",
|
||||||
|
.tags(.method)
|
||||||
|
)
|
||||||
|
struct NormalizeEmailTests {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let normalize = NormalizeEmail()
|
||||||
|
|
||||||
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
("fan@loudmail.nl", "fan@loudmail.nl"),
|
||||||
|
("Fan@LoudMail.NL", "fan@loudmail.nl"),
|
||||||
|
(" fan@loudmail.nl\n", "fan@loudmail.nl"),
|
||||||
|
("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"),
|
||||||
|
// Exactly 254 bytes: the longest address the validator accepts.
|
||||||
|
(String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"),
|
||||||
|
])
|
||||||
|
func `normalizes a valid address`(
|
||||||
|
submitted: String,
|
||||||
|
expected: String
|
||||||
|
) {
|
||||||
|
#expect(normalize(submitted) == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"not-an-email",
|
||||||
|
"missing@dot",
|
||||||
|
"@loudmail.nl",
|
||||||
|
"fan@",
|
||||||
|
"fan@.nl",
|
||||||
|
"spaced out@loudmail.nl",
|
||||||
|
"fan@loud mail.nl",
|
||||||
|
// One byte over the 254-byte limit.
|
||||||
|
String(repeating: "a", count: 243) + "@loudmail.nl",
|
||||||
|
// Few enough characters, but multibyte ones put it over the byte limit.
|
||||||
|
String(repeating: "é", count: 130) + "@loudmail.nl",
|
||||||
|
// Control characters are not whitespace, so only the dedicated check catches them.
|
||||||
|
"fan\u{00}@loudmail.nl",
|
||||||
|
"fan@loudmail.nl\u{7F}",
|
||||||
|
] as [String?])
|
||||||
|
func `rejects a missing or invalid address`(
|
||||||
|
submitted: String?
|
||||||
|
) {
|
||||||
|
#expect(normalize(submitted) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
/// Tests exercising a method of the Utility package.
|
||||||
|
@Tag static var method: Tag
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
# Copy this file to `.env` and adjust values as needed.
|
# Local `.env` file used solely for Development purposes.
|
||||||
# cp .env.example .env
|
|
||||||
#
|
|
||||||
# Compose reads `.env` automatically to fill the ${VAR} placeholders in
|
|
||||||
# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via
|
|
||||||
# swift-configuration (allowMissing: true), so any extra app config keys placed
|
|
||||||
# here are picked up by the running service too.
|
|
||||||
|
|
||||||
# --- Image / deployment -------------------------------------------------------
|
# --- Image / deployment -------------------------------------------------------
|
||||||
|
|
||||||
@@ -39,7 +33,7 @@ IMAGE_TAG=latest
|
|||||||
HTTP_SERVER_NAME=SiteWebsite
|
HTTP_SERVER_NAME=SiteWebsite
|
||||||
|
|
||||||
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# --- Persistence ----------------------------------------------------------------
|
# --- Persistence ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "YES"
|
||||||
|
customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
# ================================
|
||||||
|
# Asset image
|
||||||
|
# ================================
|
||||||
|
FROM node:22-alpine AS assets
|
||||||
|
|
||||||
|
ARG ESBUILD_VERSION=0.28.1
|
||||||
|
ARG OXIPNG_VERSION=9.1.5
|
||||||
|
ARG SVGO_VERSION=4.0.2
|
||||||
|
|
||||||
|
# Install the minifiers in their own layer, so they are cached across asset changes.
|
||||||
|
# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base
|
||||||
|
# image advances.
|
||||||
|
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
|
||||||
|
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
|
||||||
|
|
||||||
|
# Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress the PNG images in place, keeping
|
||||||
|
# their names so the URL paths derived from the StaticFile enumeration stay unchanged.
|
||||||
|
WORKDIR /static
|
||||||
|
COPY ./Services/Website/Resources/Static .
|
||||||
|
RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \
|
||||||
|
&& esbuild --minify --allow-overwrite --outdir=js js/*.js \
|
||||||
|
&& oxipng --opt max --strip safe *.png \
|
||||||
|
&& svgo --recursive --folder .
|
||||||
|
|
||||||
|
# Export stage: `docker build --target assets-export --output <dir>` writes the minified static files to <dir> for
|
||||||
|
# local inspection.
|
||||||
|
FROM scratch AS assets-export
|
||||||
|
COPY --from=assets /static /
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
# Build image
|
# Build image
|
||||||
# ================================
|
# ================================
|
||||||
@@ -14,16 +43,23 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# First just resolve dependencies.
|
# First just resolve dependencies.
|
||||||
# This creates a cached layer that can be reused as long as the manifests do
|
# This creates a cached layer that can be reused as long as the manifests do not change. The Website package depends on
|
||||||
# not change. The Website package depends on the local Localization package via
|
# the local Localization package via a relative path, so its manifest must be present for resolution to succeed.
|
||||||
# a relative path, so its manifest must be present for resolution to succeed.
|
|
||||||
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
|
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
|
||||||
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
|
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
|
||||||
|
COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/
|
||||||
|
COPY ./Packages/Utility/Package.swift ./Packages/Utility/
|
||||||
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
|
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
|
||||||
RUN swift package --package-path ./Services/Website resolve
|
RUN swift package --package-path ./Services/Website resolve
|
||||||
|
|
||||||
# Copy entire repo into container
|
# Copy only the Swift inputs needed for a release build. Static assets are built in the assets stage and copied into
|
||||||
COPY . .
|
# staging after the binary is produced.
|
||||||
|
COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources
|
||||||
|
COPY ./Packages/Localization/Sources ./Packages/Localization/Sources
|
||||||
|
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
||||||
|
COPY ./Packages/Utility/Sources ./Packages/Utility/Sources
|
||||||
|
COPY ./Services/Website/Sources ./Services/Website/Sources
|
||||||
|
COPY ./Services/Website/Tests ./Services/Website/Tests
|
||||||
|
|
||||||
# Build the application, with optimizations, with static linking, and using jemalloc
|
# Build the application, with optimizations, with static linking, and using jemalloc
|
||||||
RUN swift build --package-path ./Services/Website -c release \
|
RUN swift build --package-path ./Services/Website -c release \
|
||||||
@@ -43,9 +79,12 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
|||||||
# Copy resources bundled by SPM to staging area
|
# Copy resources bundled by SPM to staging area
|
||||||
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||||
|
|
||||||
# Copy the static files directory (served by FileMiddleware) if it exists
|
# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
|
||||||
|
RUN mkdir -p ./Resources/Static
|
||||||
|
COPY --from=assets /static ./Resources/Static
|
||||||
|
|
||||||
# Ensure that by default, neither the directory nor any of its contents are writable.
|
# Ensure that by default, neither the directory nor any of its contents are writable.
|
||||||
RUN [ -d /build/Services/Website/Resources ] && { mv /build/Services/Website/Resources ./Resources && chmod -R a-w ./Resources; } || true
|
RUN chmod -R a-w ./Resources
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
# Run image
|
# Run image
|
||||||
@@ -59,6 +98,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
|||||||
&& apt-get -q install -y \
|
&& apt-get -q install -y \
|
||||||
libjemalloc2 \
|
libjemalloc2 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
curl \
|
||||||
tzdata \
|
tzdata \
|
||||||
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
||||||
# libcurl4 \
|
# libcurl4 \
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ db-reset: ## Stop and remove the local database instance and delete its data vol
|
|||||||
--profile database down mariadb \
|
--profile database down mariadb \
|
||||||
--volumes
|
--volumes
|
||||||
|
|
||||||
|
# --- Assets minification ------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: ast-minify
|
||||||
|
ast-minify: ## Preview the minified JS/CSS assets in .build/minified
|
||||||
|
@docker build \
|
||||||
|
--target assets-export \
|
||||||
|
--output .build/minified \
|
||||||
|
--file Dockerfile \
|
||||||
|
../..
|
||||||
|
@echo "Minified assets written to .build/minified"
|
||||||
|
|
||||||
# --- Registry deployment ------------------------------------------------------
|
# --- Registry deployment ------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: img-check
|
.PHONY: img-check
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ let package = Package(
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
path: "../../Packages/Infrastructure"
|
||||||
|
),
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Localization"
|
path: "../../Packages/Localization"
|
||||||
),
|
),
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Persistence"
|
path: "../../Packages/Persistence"
|
||||||
),
|
),
|
||||||
|
.package(
|
||||||
|
path: "../../Packages/Utility"
|
||||||
|
),
|
||||||
.package(
|
.package(
|
||||||
url: "https://github.com/elementary-swift/elementary.git",
|
url: "https://github.com/elementary-swift/elementary.git",
|
||||||
from: "0.6.0"
|
from: "0.6.0"
|
||||||
@@ -55,6 +61,7 @@ let package = Package(
|
|||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "Website",
|
name: "Website",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Localization"),
|
||||||
.byName(name: "Persistence"),
|
.byName(name: "Persistence"),
|
||||||
.byName(name: "WebsiteLibrary"),
|
.byName(name: "WebsiteLibrary"),
|
||||||
.product(
|
.product(
|
||||||
@@ -75,8 +82,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "WebsiteLibrary",
|
name: "WebsiteLibrary",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Infrastructure"),
|
||||||
.byName(name: "Localization"),
|
.byName(name: "Localization"),
|
||||||
.byName(name: "Persistence"),
|
.byName(name: "Persistence"),
|
||||||
|
.byName(name: "Utility"),
|
||||||
.product(
|
.product(
|
||||||
name: "Configuration",
|
name: "Configuration",
|
||||||
package: "swift-configuration"
|
package: "swift-configuration"
|
||||||
@@ -104,6 +113,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",
|
||||||
@@ -115,6 +125,8 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "WebsiteLibraryTests",
|
name: "WebsiteLibraryTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Infrastructure"),
|
||||||
|
.byName(name: "Persistence"),
|
||||||
.byName(name: "WebsiteLibrary"),
|
.byName(name: "WebsiteLibrary"),
|
||||||
.product(
|
.product(
|
||||||
name: "Elementary",
|
name: "Elementary",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ The service:
|
|||||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
||||||
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
||||||
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
|
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
|
||||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
|
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
|
||||||
- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
|
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
|
||||||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||||||
- Stamps a hardened set of security headers on every response.
|
- Stamps a hardened set of security headers on every response.
|
||||||
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
|
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
|
||||||
@@ -22,11 +22,13 @@ Two SwiftPM targets:
|
|||||||
| Target | Kind | Path | Role |
|
| Target | Kind | Path | Role |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
|
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
|
||||||
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
|
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages, the `StaticFile` asset catalog, the request context, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
|
||||||
|
|
||||||
The `Website` executable depends on two local packages:
|
The `Website` executable depends on four local packages:
|
||||||
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
||||||
|
- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
|
||||||
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
||||||
|
- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
|
||||||
|
|
||||||
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
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).
|
||||||
|
|
||||||
@@ -34,9 +36,10 @@ Requests pass through the middleware chain in this order (outermost first), then
|
|||||||
```
|
```
|
||||||
LogRequestsMiddleware
|
LogRequestsMiddleware
|
||||||
→ SecurityHeadersMiddleware (security headers on every response)
|
→ SecurityHeadersMiddleware (security headers on every response)
|
||||||
|
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
||||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||||
→ LocalizationMiddleware (negotiates the request's language)
|
→ LocalizationMiddleware (negotiates the request's language)
|
||||||
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
|
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||||
→ FileMiddleware (serves Resources/Static)
|
→ FileMiddleware (serves Resources/Static)
|
||||||
RootController (GET / → landing page)
|
RootController (GET / → landing page)
|
||||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||||
@@ -47,8 +50,13 @@ Configuration is read through [swift-configuration](https://github.com/apple/swi
|
|||||||
the following sources, **highest precedence first**:
|
the following sources, **highest precedence first**:
|
||||||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||||||
2. Process environment variables
|
2. Process environment variables
|
||||||
3. A `.env` file in the working directory (optional)
|
3. A `.env.local` file in the working directory (optional)
|
||||||
4. Built-in defaults
|
4. A `.env` file in the working directory (optional)
|
||||||
|
5. Built-in defaults
|
||||||
|
|
||||||
|
The two files play different roles:
|
||||||
|
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
|
||||||
|
- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
|
||||||
|
|
||||||
### Environment variable naming
|
### Environment variable naming
|
||||||
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
|
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
|
||||||
@@ -59,7 +67,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
|||||||
### Static file caching
|
### 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). |
|
||||||
|
|
||||||
@@ -100,6 +109,14 @@ See [Persistence](#persistence-1) below for the workflow.
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
||||||
|
|
||||||
|
### Rate limiting
|
||||||
|
These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
|
||||||
|
| Config key | Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||||
|
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
|
||||||
|
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. |
|
||||||
|
|
||||||
### Security headers
|
### Security headers
|
||||||
| Config key | Environment variable | Default |
|
| Config key | Environment variable | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -119,6 +136,8 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
|
|||||||
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
|
||||||
|
|
||||||
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
||||||
```sh
|
```sh
|
||||||
make pkg-build # swift build
|
make pkg-build # swift build
|
||||||
@@ -158,13 +177,15 @@ DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
|
|||||||
### Health checks
|
### Health checks
|
||||||
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
|
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
|
||||||
|
|
||||||
|
`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
```sh
|
```sh
|
||||||
make pkg-test
|
make pkg-test
|
||||||
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
|
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`.
|
||||||
|
|
||||||
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
|
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
|
||||||
```sh
|
```sh
|
||||||
@@ -192,6 +213,42 @@ docker compose -f docker-compose.yml pull
|
|||||||
docker compose -f docker-compose.yml up -d
|
docker compose -f docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Static assets
|
||||||
|
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit:
|
||||||
|
- CSS and JS are minified with [esbuild](https://esbuild.github.io).
|
||||||
|
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — the output is pixel-identical, only encoded smaller.
|
||||||
|
- The SVG icon is minified with [svgo](https://github.com/svg/svgo).
|
||||||
|
|
||||||
|
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
|
||||||
|
|
||||||
|
The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
|
||||||
|
|
||||||
|
Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
|
||||||
|
```sh
|
||||||
|
make ast-minify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
All icons are renditions of the star mark in `icon.svg`, which is the canonical source — there is no external design file to regenerate from.
|
||||||
|
|
||||||
|
| File | Size | Used by | Dark mode |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `icon.svg` | vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded `prefers-color-scheme` style flips the accent paths from `#000` to `#f3ecf5`. |
|
||||||
|
| `favicon.ico` | 32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral **by design**: it is the orange star *without* the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
|
||||||
|
| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
|
||||||
|
| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
|
||||||
|
|
||||||
|
The landing page pairs the icons with two `theme-color` metas: `#0c0710` (media-qualified for dark, listed first — the first matching entry wins) and `#fafafa` as the fallback.
|
||||||
|
|
||||||
|
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. for the 512px rendition:
|
||||||
|
```sh
|
||||||
|
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
|
||||||
|
apk add --no-cache imagemagick librsvg oxipng &&
|
||||||
|
magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
|
||||||
|
oxipng --opt max --strip safe /work/icon-512.png'
|
||||||
|
```
|
||||||
|
(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
|
||||||
|
|
||||||
### Required variables
|
### Required variables
|
||||||
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
|
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
|
||||||
| Variable | Used for |
|
| Variable | Used for |
|
||||||
@@ -205,8 +262,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
|
|||||||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||||
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
||||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||||||
| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
|
| `DATABASE_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. |
|
||||||
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
|
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
|
||||||
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). |
|
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
|
||||||
|
|
||||||
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 766 B After Width: | Height: | Size: 700 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 192 192"><path fill="#e08524" d="M75.3 73.4H18.4l45.3 34.3L48.3 163l46.1-32.3 48.2 34.6-16.9-58.3 44.9-33.6H115l-20.5-55-19.2 55z"/><path d="m96.7 18.8 18.2 8.2 16.5 44.3h-15.1L96.7 18.8zm-47 146 18.7 9.9 42.6-29.9-16.5-11.4-44.8 31.4zm79.1-56.8 17.4 9.4 18.6 60.1-19.7-11.3-16.3-58.2z"/><path d="m173.1 74.3 17.8 9.2-44.7 34-17.4-9.4 44.3-33.8z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 192 192"><style>.accent{fill:#000}@media (prefers-color-scheme:dark){.accent{fill:#f3ecf5}}</style><path fill="#e08524" d="M75.3 73.4H18.4l45.3 34.3L48.3 163l46.1-32.3 48.2 34.6-16.9-58.3 44.9-33.6H115l-20.5-55-19.2 55z"/><path class="accent" d="m96.7 18.8 18.2 8.2 16.5 44.3h-15.1L96.7 18.8zm-47 146 18.7 9.9 42.6-29.9-16.5-11.4-44.8 31.4zm79.1-56.8 17.4 9.4 18.6 60.1-19.7-11.3-16.3-58.2z"/><path class="accent" d="m173.1 74.3 17.8 9.2-44.7 34-17.4-9.4 44.3-33.8z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 549 B |