Initial commit.

This commit is contained in:
2026-08-19 23:19:08 +02:00
commit 22e737d9c2
153 changed files with 11680 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Build context is the repo root (see Services/Website/Dockerfile). Keep the
# context lean: exclude build artifacts, VCS, IDE cruft, secrets and anything
# not needed to compile the Swift package.
# Version control
.git
**/.git
# Swift / SPM build artifacts
**/.build
**/.swiftpm
# Test sources
**/Tests/DB
# Xcode project (not used by the Linux build)
*.xcodeproj
# OS / editor / tooling cruft
**/.DS_Store
.vscode
.claude
# Local environment overrides and secrets (Compose still reads these from the
# host at runtime; ignoring them here only keeps them out of the image build).
**/.env
**/.env.local
# Compose / tooling / docs not needed inside the image
**/docker-compose.*
**/Makefile
**/README.md
+61
View File
@@ -0,0 +1,61 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
# Xcode
## User settings
xcuserdata/
## Build generated
build/
DerivedData/
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Other
*.moved-aside
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
## Build output and resolved dependency checkouts
.build/
## SwiftPM's local registry config and per-user Xcode data (schemes live in
## xcuserdata, already ignored above). Shared .swiftpm metadata is committed.
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
# NOTE: `Packages/` is intentionally NOT ignored — it holds this repo's local
# Swift packages (source), not checked-out dependencies.
# NOTE: `Package.resolved` is intentionally NOT ignored — committing it locks
# dependency versions across the team and CI.
# Docker / environment
.env
!.env.local
## Local PostgreSQL data files
Services/Website/Tests/DB/
# Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
+65
View File
@@ -0,0 +1,65 @@
{
"version": 1,
"lineLength": 200,
"indentation": {
"spaces": 4
},
"tabWidth": 4,
"maximumBlankLines": 1,
"respectsExistingLineBreaks": true,
"lineBreakBeforeControlFlowKeywords": true,
"lineBreakBeforeEachArgument": true,
"lineBreakBeforeEachGenericRequirement": true,
"lineBreakBetweenDeclarationAttributes": true,
"prioritizeKeepingFunctionOutputTogether": false,
"indentConditionalCompilationBlocks": true,
"indentSwitchCaseLabels": false,
"lineBreakAroundMultilineExpressionChainComponents": false,
"spacesAroundRangeFormationOperators": true,
"multiElementCollectionTrailingCommas": true,
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"noAssignmentInExpressions": {
"allowedFunctions": [
"XCTAssertNoThrow"
]
},
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"BeginDocumentationCommentWithOneLineSummary": true,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": true,
"NeverUseForceTry": true,
"NeverUseImplicitlyUnwrappedOptionals": true,
"NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true,
"NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoVoidReturnOnFunctionSignature": true,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"UseEarlyExits": true,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": false,
"ValidateDocumentationComments": false
}
}
+94
View File
@@ -0,0 +1,94 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 110;
objects = {
/* Begin PBXFileSystemSynchronizedRootGroup section */
02B32DD62FEEC5A700DA6788 /* Apps */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Apps;
sourceTree = "<group>";
};
02B32DD72FEEC5AE00DA6788 /* Services */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Services;
sourceTree = "<group>";
};
02B32DD82FEEC5B400DA6788 /* Packages */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Packages;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXGroup section */
02642FD02FEEB9A5007FA466 = {
isa = PBXGroup;
children = (
02B32DD82FEEC5B400DA6788 /* Packages */,
02B32DD72FEEC5AE00DA6788 /* Services */,
02B32DD62FEEC5A700DA6788 /* Apps */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXProject section */
02642FD12FEEB9A5007FA466 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastUpgradeCheck = 2700;
ORGANIZATIONNAME = "Röck+Cöde VoF";
};
buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "CCN" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 02642FD02FEEB9A5007FA466;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 110;
projectDirPath = "";
projectRoot = "";
targets = (
);
};
/* End PBXProject section */
/* Begin XCBuildConfiguration section */
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "CCN" */ = {
isa = XCBuildConfiguration;
buildSettings = {
DEVELOPMENT_TEAM = 7FMNM89WKG;
};
name = Debug;
};
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "CCN" */ = {
isa = XCBuildConfiguration;
buildSettings = {
DEVELOPMENT_TEAM = 7FMNM89WKG;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "CCN" */ = {
isa = XCConfigurationList;
buildConfigurations = (
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "CCN" */,
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "CCN" */,
);
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 02642FD12FEEB9A5007FA466 /* Project object */;
validationLevel = 1;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -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>
+68
View File
@@ -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")
]
),
]
)
+51
View File
@@ -0,0 +1,51 @@
# 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
| 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 |
| Analytics | `Analytics`, the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets |
| 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, the `document:` closure building a page for a locale, and a `Page` conformer's `metadata` plus its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive fully formed and absolute; composing them stays with the page.
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready representation — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — and `Page` applies it without knowing the vocabulary. Page ``scripts`` and the tracker render as `defer`red head tags, with a `preconnect` hint for the tracker's cross-origin host.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `*+Defaults` extensions are the pattern. The open schema.org vocabularies extend the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, and a service adds its own.
- **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/ Analytics, SocialCard, StructuredData (the latter two nesting their own 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 for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling 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 folder within the static root that holds files with this extension, if any.
var folder: String? {
switch self {
case .css: "css"
case .js: "js"
default: nil
}
}
}
@@ -0,0 +1,12 @@
import HTTPTypes
public extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
static let frameOptions = Self("X-Frame-Options")!
/// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`).
static let xForwardedFor = Self("X-Forwarded-For")!
}
@@ -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
}
}
@@ -0,0 +1,59 @@
import Foundation
import HTTPTypes
import Hummingbird
import Localization
/// Resolves the visitor's preferred language and records it on the request context.
///
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
///
/// The request is otherwise passed through untouched the URL and routing are not affected so each page is served at its existing path and varies its
/// content by header.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// Negotiates the request's language from its `Accept-Language` header.
private let negotiate: Negotiate
// MARK: Initializers
/// Creates a localization middleware that negotiates against the given bundle's String Catalog languages.
/// - Parameter bundle: the bundle whose String Catalog names the supported languages.
public init(
bundle: Bundle
) {
self.negotiate = .init(bundle: bundle)
}
}
// MARK: - RouterMiddleware
extension LocalizationMiddleware: RouterMiddleware {
// MARK: Functions
/// Negotiates the request's language and records it on the context before passing it down.
/// - 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.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var context = context
context.language = negotiate(
acceptLanguage: request.headers[.acceptLanguage]
)
return try await next(request, context)
}
}
@@ -0,0 +1,74 @@
import Elementary
import Foundation
import Hummingbird
/// Serves a custom error page for requests that match neither a route nor a static file.
///
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
/// ``LocalizationMiddleware``, falling back to the default language.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// The error page, rendered once per supported language and reused for every not-found response.
private let responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// Creates a not-found middleware.
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
/// - document: builds the error page to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
document: (Locale) -> Document
) {
self.responses = .init(
bundle: bundle,
status: .notFound,
document: document
)
}
}
// MARK: - RouterMiddleware
extension NotFoundMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain, rendering the error page if it results in a not-found response.
///
/// Any error other than `.notFound` is rethrown unchanged.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status.
/// - Throws: any non-not-found error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
}
catch let error {
guard
let responseError = error as? any HTTPResponseError,
responseError.status == .notFound
else {
throw error
}
return responses.response(
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"
}
@@ -0,0 +1,154 @@
import HTTPTypes
import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up the rendered pages, the
/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies the strict, hardened
/// interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of
/// header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
/// The precomputed headers applied to every response.
private let fields: HTTPFields
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with
/// `Strict-Transport-Security` left off (see ``Configuration``).
public init(
configuration: Configuration = .init()
) {
self.fields = configuration.fields
}
}
// MARK: - RouterMiddleware
extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the response on the way back up.
///
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response
/// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers.
/// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
/// - Throws: any downstream error that does not render as an HTTP response.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response: Response
do {
response = try await next(
request,
context
)
} catch let error as any HTTPResponseError {
response = try error.response(
from: request,
context: context
)
}
for field in fields {
response.headers[field.name] = field.value
}
return response
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddleware.Configuration {
// MARK: Computed
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
var fields: HTTPFields {
var fields = HTTPFields()
fields[.contentSecurityPolicy] = contentSecurityPolicy
fields[.xContentTypeOptions] = contentTypeOptions
fields[.frameOptions] = frameOptions
fields[.referrerPolicy] = referrerPolicy
fields[.permissionsPolicy] = permissionsPolicy
fields[.strictTransportSecurity] = strictTransportSecurity
return fields
}
}
// MARK: - Configuration
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays
/// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on
/// (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
/// The `Content-Security-Policy` value (controls which sources the browser will load).
public let contentSecurityPolicy: String?
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
public let contentTypeOptions: String?
/// The `X-Frame-Options` value (controls whether the page may be framed).
public let frameOptions: String?
/// The `Referrer-Policy` value (controls how much referrer information is shared).
public let referrerPolicy: String?
/// The `Permissions-Policy` value (gates access to powerful browser features).
public let permissionsPolicy: String?
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
public let strictTransportSecurity: String?
// MARK: Initializers
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults
/// to `nil` (omitted). Pass `nil` for any header to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
/// - frameOptions: the `X-Frame-Options` value.
/// - referrerPolicy: the `Referrer-Policy` value.
/// - permissionsPolicy: the `Permissions-Policy` value.
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
public init(
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
contentTypeOptions: String? = String.Security.contentTypeOptions,
frameOptions: String? = String.Security.frameOptions,
referrerPolicy: String? = String.Security.referrerPolicy,
permissionsPolicy: String? = String.Security.permissionsPolicy,
strictTransportSecurity: String? = nil
) {
self.contentSecurityPolicy = contentSecurityPolicy
self.contentTypeOptions = contentTypeOptions
self.frameOptions = frameOptions
self.referrerPolicy = referrerPolicy
self.permissionsPolicy = permissionsPolicy
self.strictTransportSecurity = strictTransportSecurity
}
}
}
@@ -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,87 @@
/// 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. Each file lands in its extension's own
/// folder unless the asset names a ``folder`` of its own.
public protocol Asset: Sendable {
// MARK: Properties
/// The folder within the static files root that holds the asset's files, or `nil` (the default) to use each extension's own folder.
var folder: String? { get }
/// 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: Computed
/// The asset's files live in each extension's own folder by default.
var folder: String? { nil }
// 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 (folder ?? fileExtension.folder)
.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,207 @@
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, the analytics
/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as 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 analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
var analytics: Analytics? { get }
/// 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 as the document body.
@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 from the document head, in order.
///
/// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the
/// document is fully parsed the same semantics end-of-body tags would give, minus the late download start.
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``; the ``scripts`` load deferred from the ``head``.
@HTMLBuilder
var body: some HTML {
content
}
/// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard``
/// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the
/// ``stylesheets`` links and the deferred ``scripts`` tags, 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. The analytics tracker, by contrast, is an executable script the
/// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled.
/// When recorder mode is on, the session recorder script follows the tracker script, deferred as well and carrying only the website id.
@HTMLBuilder
var head: some HTML {
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
if let origin = analytics?.origin {
link(
.rel("preconnect"),
.href(origin)
)
}
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)
}
}
if let analytics {
script(
.defer,
.src(analytics.scriptURL)
) {}
.attributes(contentsOf: analytics.attributes.map {
.custom(
name: $0.name,
value: $0.value
)
})
if let recorderScriptURL = analytics.recorderScriptURL {
script(
.defer,
.src(recorderScriptURL),
.custom(
name: "data-website-id",
value: analytics.websiteID
)
) {}
}
}
metadata
for file in stylesheets {
link(
.rel(.stylesheet),
.href(file.urlPath(
for: .css,
version: assetVersion
))
)
}
for file in scripts {
script(
.defer,
.src(file.urlPath(
for: .js,
version: assetVersion
))
) {}
}
}
/// The analytics tracker is omitted unless the page provides one.
var analytics: Analytics? {
nil
}
/// 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)
}
)
}
}
@@ -0,0 +1,75 @@
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone.
public struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
/// The supported languages and default language, derived from the bundle's String Catalog.
private let list: LanguageList
/// The pre-rendered responses, keyed by language identifier.
private let responses: [String: CachedHTMLResponse]
// MARK: Initializers
/// Renders the document once per supported language.
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the document is rendered for.
/// - status: the status applied to every response. Defaults to `.ok`.
/// - document: builds the document to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
status: HTTPResponse.Status = .ok,
document: (Locale) -> Document
) {
self.list = .init(bundle: bundle)
self.responses = list.all
.reduce(into: [:]) { responses, language in
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
document: document(.init(
identifier: language
))
)
}
}
// MARK: Methods
/// Builds the response for the given language, falling back to the default language.
/// - Parameters:
/// - language: the negotiated language identifier.
/// - request: the request the response answers, consulted for conditional revalidation.
/// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a
/// `500 Internal Server Error` if neither is cached.
public func response(
for language: String,
request: Request
) -> Response {
guard
let response = responses[language] ?? responses[list.default]
else {
return .init(
status: .internalServerError
)
}
return response.response(
for: request
)
}
}
@@ -0,0 +1,115 @@
import Foundation
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
///
/// The behavior flags default to on and render their `data-` attributes only when enabled, since the tracker treats an absent attribute as off.
/// Recorder mode defaults to off; when on, the page embeds a second deferred script loading the session recorder from the tracker's origin.
public struct Analytics: Sendable {
// MARK: Properties
/// Whether the tracker drops the URL fragment from reported pageviews, so fragment navigation does not split a page's views.
public let excludeHash: Bool
/// Whether the tracker honors the visitor's browser Do Not Track preference.
public let doNotTrack: Bool
/// The comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
public let domains: String
/// Whether the tracker collects Core Web Vitals from visitors (requires an Umami instance at v3.1 or newer).
public let performance: Bool
/// Whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker.
public let recorder: Bool
/// The URL the tracker script is loaded from.
public let scriptURL: String
/// The analytics website identifier the tracker reports as.
public let websiteID: String
// MARK: Initializers
/// Creates an analytics configuration.
/// - Parameters:
/// - scriptURL: the URL the tracker script is loaded from.
/// - websiteID: the analytics website identifier the tracker reports as.
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
/// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`.
/// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`.
/// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`.
/// - recorder: whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker; defaults to `false`.
public init(
scriptURL: String,
websiteID: String,
domains: String,
excludeHash: Bool = true,
doNotTrack: Bool = true,
performance: Bool = true,
recorder: Bool = false
) {
self.scriptURL = scriptURL
self.websiteID = websiteID
self.domains = domains
self.excludeHash = excludeHash
self.doNotTrack = doNotTrack
self.performance = performance
self.recorder = recorder
}
// MARK: Computed
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
public var attributes: [Attribute] {
var attributes: [Attribute] = [
.init("data-website-id", value: websiteID),
.init("data-domains", value: domains)
]
if !domains.isEmpty {
attributes.append(.init("data-domains", value: domains))
}
if excludeHash {
attributes.append(.init("data-exclude-hash", value: "true"))
}
if doNotTrack {
attributes.append(.init("data-do-not-track", value: "true"))
}
if performance {
attributes.append(.init("data-performance", value: "true"))
}
return attributes
}
/// The origin the tracker is served from (scheme, host, and any explicit port), derived from the ``scriptURL``; `nil` when the URL
/// carries no scheme or host. Rendered as a `preconnect` hint before the tracker script.
public var origin: String? {
guard
let url = URL(string: scriptURL),
let scheme = url.scheme,
let host = url.host
else {
return nil
}
let port = url.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
/// The URL the session recorder script is loaded from; `nil` when ``recorder`` mode is off or no ``origin`` can be derived.
/// Rendered as a second deferred script carrying only the `data-website-id` attribute.
public var recorderScriptURL: String? {
guard recorder, let origin else {
return nil
}
return "\(origin)/recorder.js"
}
}
@@ -0,0 +1,31 @@
extension Analytics {
/// A name-value pair rendered as an HTML attribute.
///
/// The ``name`` is a full `data-` attribute name applied verbatim except inside ``Analytics/Event/properties``, where it is the bare
/// key the event prefixes on render.
public struct Attribute: Equatable, Sendable {
// MARK: Properties
/// The attribute's name.
public let name: String
/// The attribute's value.
public let value: String
// MARK: Initializers
/// Creates a tracker attribute.
/// - Parameters:
/// - name: the attribute's name.
/// - value: the attribute's value.
public init(
_ name: String,
value: String
) {
self.name = name
self.value = value
}
}
}
@@ -0,0 +1,55 @@
extension Analytics {
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
///
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
public struct Event: Equatable, Sendable {
// MARK: Properties
/// The event's name, which is what a report groups by.
public let name: String
/// The event's properties, in declaration order, which is the order they render.
///
/// Each ``Attribute/name`` is the bare key `set`, not `data-umami-event-set`; ``attributes`` adds the prefix.
public let properties: [Attribute]
// MARK: Initializers
/// Creates an event.
/// - Parameters:
/// - name: the event's name, which is what a report groups by.
/// - properties: the event's properties as bare keys `set`, not `data-umami-event-set` rendered in the order written.
/// Empty by default.
public init(
name: String,
properties: KeyValuePairs<String, String> = [:]
) {
self.name = name
self.properties = properties.map {
.init($0.key, value: $0.value)
}
}
// MARK: Computed
/// The event's attributes the event name, then each property as full `data-` attribute names an element applies verbatim.
public var attributes: [Attribute] {
[.init(Constant.Name.prefix, value: name)]
+ properties.map {
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
}
}
}
}
// MARK: - Constants
private enum Constant {
enum Name {
/// The attribute name the tracker watches for, and the prefix each property renders under.
static let prefix = "data-umami-event"
}
}
@@ -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.folders
))
func `folder`(
for fileExtension: AssetExtension,
expects folder: String?
) {
#expect(fileExtension.folder == folder)
}
}
// 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 folders: [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)
}
}
@@ -0,0 +1,72 @@
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdTesting
import NIOCore
import Testing
@testable import Infrastructure
@Suite(
"LocalizationMiddleware middleware",
.tags(.middleware)
)
struct LocalizationMiddlewareTests {
// MARK: Constants
private let app: Application = .init(router: {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
}
router.get("language") { _, context in
context.language
}
return router
}())
// MARK: Functional tests
@Test
func `negotiates a supported language from the header`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get,
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
) { response in
#expect(String(buffer: response.body) == "de")
}
}
}
@Test
func `falls back to the default without a header`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get
) { response in
#expect(String(buffer: response.body) == "en")
}
}
}
@Test
func `falls back to the default for an unsupported language`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get,
headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
) { response in
#expect(String(buffer: response.body) == "en")
}
}
}
}
@@ -0,0 +1,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)
}
}
@@ -0,0 +1,159 @@
import Hummingbird
import HummingbirdTesting
import Testing
@testable import Infrastructure
@Suite(
"SecurityHeadersMiddleware middleware",
.tags(.middleware)
)
struct SecurityHeadersMiddlewareTests {
// MARK: Functional tests
@Test
func `applies the default security headers to a response`() 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.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)
}
}
}
@Test
func `omits strict-transport-security by default`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == nil)
}
}
}
@Test
func `applies strict-transport-security when configured`() async throws {
let value = "max-age=31536000; includeSubDomains"
try await app(
configuration: .init(strictTransportSecurity: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == value)
}
}
}
@Test
func `applies a custom header value`() async throws {
let value = "default-src 'none'"
try await app(
configuration: .init(contentSecurityPolicy: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.contentSecurityPolicy] == value)
}
}
}
@Test
func `omits a header whose configured value is nil`() async throws {
try await app(
configuration: .init(contentTypeOptions: nil)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.xContentTypeOptions] == nil)
}
}
}
@Test
func `replaces an existing header value set downstream`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/weak",
method: .get
) { response in
#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)
}
}
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddlewareTests {
// MARK: Methods
/// Builds an application whose router applies the security-headers middleware ahead of three
/// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a
/// deliberately weak `X-Content-Type-Options` value for the middleware to override, and
/// `/throws` fails with an `HTTPError` the way the controllers do on invalid input.
func app(
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
) -> some ApplicationProtocol {
let router = Router()
router.addMiddleware {
SecurityHeadersMiddleware(configuration: configuration)
}
router.get("hello") { _, _ in
"Hello!"
}
router.get("weak") { _, _ -> Response in
var response = Response(status: .ok)
response.headers[.xContentTypeOptions] = "weak"
return response
}
router.get("throws") { _, _ -> Response in
throw HTTPError(.badRequest)
}
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,92 @@
import Testing
@testable import Infrastructure
@Suite(
"Asset protocol",
.tags(.`protocol`)
)
struct AssetTests {
// MARK: Properties
private let image = StubAsset(
fileExtensions: [.png],
fileName: "icon"
)
private let portrait = StubAsset(
folder: "img/organizer",
fileExtensions: [.jpg],
fileName: "portrait"
)
private let shared = StubAsset(
fileExtensions: [.css, .js],
fileName: "shared"
)
// MARK: Method tests
@Test
func `relative path nests the file inside its extension's folder`() {
#expect(shared.relativePath(for: .css) == "css/shared.css")
#expect(shared.relativePath(for: .js) == "js/shared.js")
}
@Test
func `relative path prefers the asset's own folder over the extension's`() {
#expect(portrait.relativePath(for: .jpg) == "img/organizer/portrait.jpg")
}
@Test
func `relative path keeps the file at the root without a folder`() {
#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,213 @@
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 defer 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 deferred in the head, after the stylesheets`() throws {
let html = StubPage().render()
let stylesheet = try #require(html.range(of: "/css/stub.css"))
let script = try #require(html.range(of: "/js/stub.js"))
let content = try #require(html.range(of: "Stub content"))
// Deferred head scripts start downloading during head parsing but still execute, in order, only after the
// document is parsed the semantics end-of-body tags gave, minus the late download start.
#expect(stylesheet.lowerBound < script.lowerBound)
#expect(script.lowerBound < content.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 `omits the analytics tracker by default`() {
let html = StubPage().render()
#expect(!html.contains("data-website-id"))
#expect(!html.contains(#"rel="preconnect""#))
}
@Test
func `renders the analytics tracker when provided`() throws {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com"
)).render()
#expect(html.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true" data-do-not-track="true" data-performance="true"></script>"#))
// The preconnect hint warms the tracker origin's connection before the parser reaches the script tag.
let preconnect = try #require(html.range(of: #"<link rel="preconnect" href="https://analytics.example.com">"#))
let script = try #require(html.range(of: #"<script defer src="https://analytics.example.com/script""#))
#expect(preconnect.lowerBound < script.lowerBound)
}
@Test
func `omits the session recorder script by default`() {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com"
)).render()
#expect(!html.contains("recorder.js"))
}
@Test
func `renders the session recorder script when recorder mode is on`() throws {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com",
recorder: true
)).render()
#expect(html.contains(#"<script defer src="https://analytics.example.com/recorder.js" data-website-id="0000-website-id"></script>"#))
let tracker = try #require(html.range(of: #"<script defer src="https://analytics.example.com/script""#))
let recorder = try #require(html.range(of: #"<script defer src="https://analytics.example.com/recorder.js""#))
#expect(tracker.lowerBound < recorder.lowerBound)
}
@Test
func `omits the analytics behavior flags that are disabled`() {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com",
excludeHash: true,
doNotTrack: false,
performance: false
)).render()
#expect(html.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true"></script>"#))
#expect(!html.contains("data-do-not-track"))
#expect(!html.contains("data-performance"))
}
@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,105 @@
import Testing
@testable import Infrastructure
@Suite(
"Analytics.Event type",
.tags(.type)
)
struct AnalyticsEventTests {
// MARK: Computed tests
@Test
func `renders the event name alone when it carries no properties`() {
let event = Analytics.Event(name: "instagram")
#expect(event.attributes.count == 1)
#expect(event.attributes[0] == Analytics.Attribute(
"data-umami-event",
value: "instagram"
))
}
@Test
func `renders each property as an attribute suffixed by its key`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event.attributes.count == 2)
#expect(event.attributes[1] == Analytics.Attribute(
"data-umami-event-set",
value: "avc-xi"
))
}
@Test
func `renders the name first, then the properties in the order given`() {
let event = Analytics.Event(
name: "interview",
properties: [
"placement": "credit",
"locale": "en",
]
)
// The order is the rendered attribute order, which a dictionary would leave to churn between builds.
#expect(event.attributes.map(\.name) == [
"data-umami-event",
"data-umami-event-placement",
"data-umami-event-locale",
])
#expect(event.attributes.map(\.value) == ["interview", "credit", "en"])
}
// MARK: Equatable tests
@Test
func `matches an event rendering the same attributes`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event == Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
))
}
@Test(arguments: [
Analytics.Event(
name: "playlist",
properties: ["set": "avc-ix"]
),
Analytics.Event(
name: "playlist",
properties: ["show": "avc-xi"]
),
Analytics.Event(
name: "apple_music",
properties: ["set": "avc-xi"]
),
Analytics.Event(name: "playlist"),
Analytics.Event(
name: "playlist",
properties: [
"set": "avc-xi",
"placement": "strip",
]
),
])
func `differs from an event rendering anything else`(
from other: Analytics.Event
) {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event != other)
}
}
@@ -0,0 +1,135 @@
import Testing
@testable import Infrastructure
@Suite(
"Analytics type",
.tags(.type)
)
struct AnalyticsTests {
// MARK: Functional tests
@Test
func `lists the website id and domains with every behavior flag on by default`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com"
)
#expect(analytics.attributes.map(\.name) == [
"data-website-id",
"data-domains",
"data-exclude-hash",
"data-do-not-track",
"data-performance",
])
#expect(analytics.attributes.map(\.value) == [
"id-123",
"example.com",
"true",
"true",
"true",
])
}
@Test
func `derives its origin from the script URL`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com"
)
#expect(analytics.origin == "https://analytics.example.com")
}
@Test
func `keeps an explicit port in its origin`() {
let analytics = Analytics(
scriptURL: "http://localhost:3000/script",
websiteID: "id-123",
domains: "localhost"
)
#expect(analytics.origin == "http://localhost:3000")
}
@Test
func `carries no origin for a script URL without a scheme or host`() {
let analytics = Analytics(
scriptURL: "/script",
websiteID: "id-123",
domains: "example.com"
)
#expect(analytics.origin == nil)
}
@Test
func `carries no recorder script URL by default`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com"
)
#expect(analytics.recorderScriptURL == nil)
}
@Test
func `derives its recorder script URL from the origin when recorder mode is on`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com",
recorder: true
)
#expect(analytics.recorderScriptURL == "https://analytics.example.com/recorder.js")
}
@Test
func `carries no recorder script URL when no origin can be derived`() {
let analytics = Analytics(
scriptURL: "/script",
websiteID: "id-123",
domains: "example.com",
recorder: true
)
#expect(analytics.recorderScriptURL == nil)
}
@Test
func `omits the disabled behavior flags`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com",
excludeHash: true,
doNotTrack: false,
performance: false
)
#expect(analytics.attributes.map(\.name) == [
"data-website-id",
"data-domains",
"data-exclude-hash",
])
}
@Test
func `omits the domains filter when it is empty`() {
// The tracker reads the attribute as an allowlist, so rendering it empty would filter out every host rather than none.
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: ""
)
#expect(!analytics.attributes.map(\.name).contains("data-domains"))
}
}
@@ -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,12 @@
import Infrastructure
/// An ``Asset`` with a fixed file name and set of extensions, optionally held in a folder of its own.
struct StubAsset: Asset {
// MARK: Properties
var folder: String? = nil
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,101 @@
import Elementary
import Foundation
import Infrastructure
/// A ``Page`` with fixed content, metadata, and stub assets.
struct StubPage: Page {
// MARK: Properties
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
/// 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.
/// - analytics: the analytics tracker rendered as a deferred script 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,
analytics: Analytics? = nil,
socialCard: SocialCard? = nil,
structuredData: StructuredData? = nil,
summary: String? = nil
) {
self.analytics = analytics
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,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 = "Localization"
BuildableName = "Localization"
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 = "LocalizationTests"
BuildableName = "LocalizationTests"
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 = "Localization"
BuildableName = "Localization"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Localization",
defaultLocalization: "en",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Localization",
targets: [
"Localization"
]
)
],
targets: [
.target(
name: "Localization",
path: "Sources",
),
.testTarget(
name: "LocalizationTests",
dependencies: [
.byName(name: "Localization")
],
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")
]
),
]
)
+43
View File
@@ -0,0 +1,43 @@
# 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
| 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`, so adding a language is a translation-only 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 resource verbatim (never `.process` it) so it ships as raw JSON everywhere. Only simple `stringUnit` values are decoded — plural and device variations are not.
- **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` at startup and warn when it is not `.loaded`.
- **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 for the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation and Synchronization only.
@@ -0,0 +1,38 @@
import Foundation
/// A backend that resolves localized strings for an explicit locale and reports the languages it serves.
///
/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored and resolved. The shipping implementation,
/// ``StringCatalog``, reads a raw `.xcstrings` catalog so it behaves identically on Darwin and Linux. A future backend for example one built on
/// `String(localized:)` for a native Apple app that needs plural and device variations can conform without changing any caller.
///
/// Resolution never fails: an implementation returns the key itself when it has no localization for it, mirroring Foundation's `String(localized:)`.
/// This is the contract both a dictionary lookup and the native API can honour, since the native API cannot distinguish a missing key from a translation that
/// happens to equal the key.
protocol CatalogResolving: Sendable {
// MARK: Properties
/// The source (development) language, used as the final fallback when a locale has no localization and as the default language a ``LanguageList``
/// serves.
var sourceLanguage: String { get }
/// The outcome of reading the backing catalog, for consumers to surface at startup.
var state: CatalogState { get }
/// Every language the backend can resolve strings for, including the source language.
var languages: Set<String> { get }
// MARK: Methods
/// Resolves a catalog key in the given locale.
/// - Parameters:
/// - key: the catalog key to look up.
/// - locale: the locale to resolve the key in.
/// - Returns: the localized string, falling back to the source language, then to the key itself.
func string(
for key: String,
in locale: Locale
) -> String
}
@@ -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
}
@@ -0,0 +1,232 @@
import Foundation
import Synchronization
/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources.
///
/// The catalog is parsed from raw JSON rather than through Foundation's compiled-catalog APIs (`String(localized:)`,
/// `Bundle.localizations`, `Bundle.preferredLocalizations`). Those are either unavailable or non-functional on non-Darwin platforms
/// (Linux), where the toolchain ships no `xcstringstool` and so copies the raw `.xcstrings` into the resource bundle instead of compiling it.
/// Reading the catalog ourselves gives identical behaviour on every platform the service builds for.
///
/// Only simple `stringUnit` values are decoded; plural and device variations are not represented.
struct StringCatalog: Sendable {
// MARK: Properties
/// The source language of the catalog, used as the fallback when a key lacks a requested localization.
let sourceLanguage: String
/// The resolved entries, keyed by catalog key then by language code.
let entries: [String: [String: String]]
/// The outcome of reading the catalog from its bundle.
let state: CatalogState
// MARK: Computed
/// Every language the catalog provides a localization for, including the source language.
var languages: Set<String> {
var languages = Set(entries.values.flatMap(\.keys))
languages.insert(sourceLanguage)
return languages
}
// MARK: Initializers
/// Reads the catalog named `table` from `bundle`.
///
/// Falls back to an empty catalog (source language `"en"`, no entries) when the resource is missing or cannot be decoded, so lookups degrade
/// to returning the key and the language list to the default.
/// - Parameters:
/// - bundle: the bundle whose resources contain the String Catalog.
/// - table: the name of the String Catalog resource, without the `.xcstrings` extension.
init(
bundle: Bundle,
table: String = "Localizable"
) {
guard let url = bundle.url(
forResource: table,
withExtension: .Extension.stringCatalog
) else {
self.sourceLanguage = .Default.sourceLanguage
self.entries = [:]
self.state = .missing
return
}
do {
let decoder = JSONDecoder()
let data = try Data(contentsOf: url)
let decoded = try decoder.decode(
Decoded.self,
from: data
)
self.sourceLanguage = decoded.sourceLanguage
self.entries = decoded.strings
.mapValues { entry in
(entry.localizations ?? [:])
.compactMapValues { $0.stringUnit?.value }
}
self.state = .loaded
} catch {
self.sourceLanguage = .Default.sourceLanguage
self.entries = [:]
self.state = .undecodable
}
}
}
// MARK: - Cache
extension StringCatalog {
/// A cache key identifying a catalog by its bundle location and table name.
private struct Key: Hashable, Sendable {
let bundle: URL
let table: String
}
/// The decoded catalogs, keyed by bundle and table.
private static let cache = Mutex<[Key: StringCatalog]>([:])
/// Returns the catalog for the given bundle and table, decoding it on first access.
///
/// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` bound to the same bundle shares one
/// decoded catalog instead of re-reading its JSON.
/// - Parameters:
/// - bundle: the bundle whose resources contain the String Catalog.
/// - table: the name of the String Catalog resource, without the `.xcstrings` extension.
/// - Returns: the decoded catalog, from the cache when it has been read before.
static func cached(
bundle: Bundle,
table: String = "Localizable"
) -> StringCatalog {
let key = Key(
bundle: bundle.bundleURL,
table: table
)
return cache.withLock { cache in
if let catalog = cache[key] {
return catalog
}
let catalog = StringCatalog(
bundle: bundle,
table: table
)
cache[key] = catalog
return catalog
}
}
}
// MARK: - 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(
for key: String,
in locale: Locale
) -> String {
guard let byLanguage = entries[key] else {
return key
}
for tag in locale.catalogTags {
if let match = byLanguage.first(
where: { $0.key.lowercased() == tag }
) {
return match.value
}
}
return byLanguage[sourceLanguage] ?? key
}
}
// MARK: - Locale+Extensions
private extension Locale {
/// The language tags to resolve a catalog entry against, most specific first.
///
/// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes `pt-br`), followed by its primary language code when
/// the two differ.
var catalogTags: [String] {
var tags: [String] = []
let identifier = identifier
.replacingOccurrences(
of: String.Separator.underscore,
with: String.Separator.dash
)
.lowercased()
if !identifier.isEmpty {
tags.append(identifier)
}
if let code = language.languageCode?.identifier.lowercased(),
code != identifier {
tags.append(code)
}
return tags
}
}
// MARK: - Decoding Types
private extension StringCatalog {
/// The subset of the `.xcstrings` format needed to resolve simple string entries.
struct Decoded: Decodable {
let sourceLanguage: String
let strings: [String: Entry]
struct Entry: Decodable {
let localizations: [String: Localization]?
}
struct Localization: Decodable {
let stringUnit: StringUnit?
}
struct StringUnit: Decodable {
let value: String
}
}
}
// MARK: - String+Constants
private extension String {
/// The source language assumed when a catalog is missing or cannot be decoded, matching the
/// package's default localization.
enum Default {
static let sourceLanguage = "en"
}
enum Extension {
static let stringCatalog = "xcstrings"
}
enum Separator {
static let dash = "-"
static let underscore = "_"
}
}
@@ -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
}
@@ -0,0 +1,69 @@
import Foundation
/// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale.
///
/// A server has no single "current" locale, so each lookup must name the locale to use. An instance is bound to the bundle whose catalog holds the strings,
/// then invoked like a function to resolve a key in a chosen locale.
public struct Localize: Sendable {
// MARK: Properties
/// The backend that resolves keys against the catalog.
private let resolver: any CatalogResolving
// MARK: Computed
/// The outcome of reading the bundle's String Catalog.
///
/// Resolution degrades to returning raw keys rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
public var catalogState: CatalogState {
resolver.state
}
// MARK: Initializers
/// Creates a localizer backed by the String Catalog in the given bundle.
/// - Parameters:
/// - bundle: the bundle whose String Catalog contains the keys to resolve.
/// - table: the name of the String Catalog resource, without the `.xcstrings` extension.
public init(
bundle: Bundle,
table: String = "Localizable"
) {
self.init(resolver: StringCatalog.cached(
bundle: bundle,
table: table
))
}
/// Creates a localizer backed by the given resolver.
///
/// The seam for tests and alternative backends; the public API resolves against a bundled catalog.
/// - Parameter resolver: the backend that resolves keys to localized strings.
init(
resolver: any CatalogResolving
) {
self.resolver = resolver
}
// MARK: Methods
/// Resolves a catalog key in the given locale.
///
/// Invoked by calling the instance directly, for example `localize("index.title", locale: locale)`.
/// - Parameters:
/// - key: the String Catalog key to look up.
/// - locale: the locale to resolve the key in.
/// - Returns: the localized string for the locale, the source-language string when the locale has no entry, or the key itself when the catalog has no
/// entry for it.
public func callAsFunction(
_ key: String,
locale: Locale
) -> String {
resolver.string(
for: key,
in: locale
)
}
}
@@ -0,0 +1,198 @@
import Foundation
/// Negotiates the best supported language for a request from its `Accept-Language` header.
///
/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a function through
/// ``callAsFunction(acceptLanguage:)`` to resolve a header value to a supported language identifier, honouring the header's `q` weights as
/// RFC 9110 prescribes and falling back to the default language.
public struct Negotiate: Sendable {
// MARK: Properties
/// The supported languages and default language, derived from the bundle's String Catalog.
private let list: LanguageList
// MARK: Initializers
/// Creates a language negotiator backed by the given bundle's String Catalog.
/// - Parameter bundle: the bundle whose String Catalog defines the supported languages.
public init(
bundle: Bundle
) {
self.list = .init(bundle: bundle)
}
// MARK: Methods
/// Picks the best supported language for the given `Accept-Language` header value.
///
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
/// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight
/// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the
/// supported languages in turn first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` while the
/// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned.
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
/// - Returns: the identifier of the supported language to serve.
public func callAsFunction(
acceptLanguage language: String?
) -> String {
guard let language else {
return list.default
}
let ranges = ranges(from: language)
guard !ranges.isEmpty else {
return list.default
}
let supported = list.all
for range in ranges {
if range.tag == .wildcard {
return list.default
}
if let match = match(range.tag, in: supported) {
return match
}
}
return list.default
}
}
// MARK: - Helpers
private extension Negotiate {
// MARK: Methods
/// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference.
///
/// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by descending weight, entries weighted 0 are dropped
/// as "not acceptable", and equally weighted entries keep the header order.
/// - Parameter acceptLanguage: the raw `Accept-Language` header value.
/// - Returns: the language ranges, most preferred first.
func ranges(
from acceptLanguage: String
) -> [LanguageRange] {
acceptLanguage
.split(separator: .Separator.comma)
.compactMap(range(from:))
.filter { $0.quality > 0 }
.enumerated()
.sorted { lhs, rhs in
lhs.element.quality == rhs.element.quality
? lhs.offset < rhs.offset
: lhs.element.quality > rhs.element.quality
}
.map(\.element)
}
/// Parses a single `Accept-Language` header entry into its ``LanguageRange``.
///
/// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight.
/// A missing or malformed weight counts as 1, the highest preference, matching a tag sent without one.
/// - Parameter entry: a single comma-separated header entry.
/// - Returns: the entry's language range, or `nil` when it has no language tag.
func range(
from entry: Substring
) -> LanguageRange? {
let parts = entry.split(separator: .Separator.semicolon)
let tag = parts.first
.map(String.init)?
.trimmingCharacters(in: .whitespaces)
?? .empty
guard !tag.isEmpty else {
return nil
}
let quality = parts
.dropFirst()
.compactMap { parameter -> Double? in
let parameter = parameter
.trimmingCharacters(in: .whitespaces)
.lowercased()
guard parameter.hasPrefix(.Prefix.quality) else {
return nil
}
return Double(parameter.dropFirst(String.Prefix.quality.count))
}
.first
?? 1
return .init(
tag: tag,
quality: quality
)
}
/// Finds the supported language that best matches a single `Accept-Language` tag.
///
/// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the supported languages' primary subtags, so a
/// regional tag such as `de-AT` resolves to `de`.
/// - Parameters:
/// - tag: a single language tag from the header.
/// - supported: the supported language identifiers.
/// - Returns: the matching supported language, or `nil` when the tag matches none.
func match(
_ tag: String,
in supported: [String]
) -> String? {
let tag = tag.lowercased()
if let exact = supported.first(
where: { $0.lowercased() == tag }
) {
return exact
}
let primary = tag.primarySubtag
return supported.first {
$0.lowercased().primarySubtag == primary
}
}
}
// MARK: - Character+Extensions
private extension Character {
// MARK: Constants
enum Separator {
static let comma: Character = ","
static let dash: Character = "-"
static let semicolon: Character = ";"
}
}
// MARK: - String+Extensions
private extension String {
// MARK: Constants
static let empty: String = ""
static let wildcard: String = "*"
enum Prefix {
static let quality = "q="
}
/// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`).
var primarySubtag: String {
split(separator: .Separator.dash)
.first
.map(String.init)
?? self
}
}
@@ -0,0 +1,61 @@
import Foundation
/// The list of languages an app can serve, derived from a bundle's String Catalog.
///
/// The languages are read from the injected `bundle`'s String Catalog, so the catalog that ships in that bundle is the single source of truth: adding
/// a language is a translation-only change once a locale exists in the catalog, it appears in ``all`` with no code change required.
public struct LanguageList: Sendable {
// MARK: Properties
/// The backend that reports the available languages.
private let resolver: any CatalogResolving
// MARK: Initializers
/// Creates a language list backed by the given bundle.
/// - Parameter bundle: the bundle whose String Catalog defines the available languages.
public init(
bundle: Bundle
) {
self.init(resolver: StringCatalog.cached(bundle: bundle))
}
/// Creates a language list backed by the given resolver.
///
/// The seam for tests and alternative backends; the public API derives languages from a bundled catalog.
/// - Parameter resolver: the backend that reports the available languages.
init(
resolver: any CatalogResolving
) {
self.resolver = resolver
}
// MARK: Computed
/// The outcome of reading the bundle's String Catalog.
///
/// The list degrades to the default language rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``.
public var catalogState: CatalogState {
resolver.state
}
/// The language served when none of the supported languages match a request.
///
/// Always the catalog's source (development) language, so the default cannot drift from the bundle the list is bound to.
public var `default`: String {
resolver.sourceLanguage
}
/// Every language the bundle's String Catalog provides a localization for.
///
/// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language.
/// The result is sorted for a stable order.
public var all: [String] {
resolver
.languages
.filter { $0 != "Base" }
.sorted()
}
}
@@ -0,0 +1,134 @@
import Foundation
import Testing
@testable import Localization
@Suite(
"Localize method",
.tags(.method)
)
struct LocalizeTests {
// MARK: Constants
private let localize = Localize(bundle: .module)
// MARK: Functional tests
@Test
func `resolves a key in the default locale`() {
let text = localize(
"test.greeting",
locale: Locale(identifier: "en")
)
#expect(text == "Hello")
}
@Test
func `resolves a key in another locale`() {
let text = localize(
"test.greeting",
locale: Locale(identifier: "de"),
)
#expect(text == "Hallo")
}
@Test
func `resolves a key in a regional locale`() {
let text = localize(
"test.greeting",
locale: Locale(identifier: "pt-BR"),
)
#expect(text == "Olá")
}
@Test
func `falls back to the primary language for an unmatched regional locale`() {
let text = localize(
"test.greeting",
locale: Locale(identifier: "de-AT"),
)
#expect(text == "Hallo")
}
@Test
func `falls back to the source language for an unsupported locale`() {
let text = localize(
"test.greeting",
locale: Locale(identifier: "fr"),
)
#expect(text == "Hello")
}
@Test
func `falls back to the key for an unknown entry`() {
let text = localize(
"unknown.key",
locale: Locale(identifier: "en"),
)
#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)
}
}
@@ -0,0 +1,130 @@
import Foundation
import Testing
@testable import Localization
@Suite(
"Negotiate method",
.tags(.method)
)
struct NegotiateTests {
// MARK: Constants
private let negotiate = Negotiate(bundle: .module)
// MARK: Functional tests
@Test
func `matches an exact language tag`() {
let language = negotiate(acceptLanguage: "de")
#expect(language == "de")
}
@Test
func `matches a regional language tag`() {
let language = negotiate(acceptLanguage: "de-AT")
#expect(language == "de")
}
@Test
func `matches a regional catalog language exactly`() {
let language = negotiate(acceptLanguage: "pt-BR")
#expect(language == "pt-BR")
}
@Test
func `matches a regional catalog language by its primary subtag`() {
let language = negotiate(acceptLanguage: "pt")
#expect(language == "pt-BR")
}
@Test
func `respects the header order of preference`() {
let language = negotiate(acceptLanguage: "de, en")
#expect(language == "de")
}
@Test
func `orders the language tags by descending quality weight`() {
let language = negotiate(acceptLanguage: "en;q=0.5, de;q=0.9")
#expect(language == "de")
}
@Test
func `treats a tag without a weight as the highest preference`() {
let language = negotiate(acceptLanguage: "en;q=0.9, de")
#expect(language == "de")
}
@Test
func `treats a tag with a malformed weight as the highest preference`() {
let language = negotiate(acceptLanguage: "en;q=0.9, de;q=broken")
#expect(language == "de")
}
@Test
func `drops language tags weighted as not acceptable`() {
let language = negotiate(acceptLanguage: "de;q=0, en;q=0.8")
#expect(language == "en")
}
@Test
func `falls back to the default when every tag is weighted as not acceptable`() {
let language = negotiate(acceptLanguage: "de;q=0, fr;q=0")
#expect(language == "en")
}
@Test
func `serves the default language for a wildcard`() {
let language = negotiate(acceptLanguage: "fr;q=0.9, *;q=0.5")
#expect(language == "en")
}
@Test
func `trims whitespace around the language tags`() {
let language = negotiate(acceptLanguage: " de , en ")
#expect(language == "de")
}
@Test
func `falls back to the default for an unsupported language`() {
let language = negotiate(acceptLanguage: "fr")
#expect(language == "en")
}
@Test
func `falls back to the default for a missing header`() {
let language = negotiate(acceptLanguage: nil)
#expect(language == "en")
}
@Test
func `falls back to the default for an empty header`() {
let language = negotiate(acceptLanguage: "")
#expect(language == "en")
}
@Test
func `falls back to the default for a header without language tags`() {
let language = negotiate(acceptLanguage: " , ;q=0.5,")
#expect(language == "en")
}
}
@@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import Localization
@Suite(
"LanguageList type",
.tags(.type)
)
struct LanguageListTests {
// MARK: Properties tests
@Suite("default")
struct Default {
@Test
func `defaults to english`() {
let list = LanguageList(
bundle: .module
)
#expect(list.default == "en")
}
@Test
func `follows the catalog's source language`() {
let list = LanguageList(
resolver: StubCatalog(
sourceLanguage: "de",
languages: ["de", "en"]
)
)
#expect(list.default == "de")
}
}
@Suite("catalogState")
struct CatalogState {
@Test
func `reports a loaded catalog`() {
let list = LanguageList(
bundle: .module
)
#expect(list.catalogState == .loaded)
}
@Test
func `reports a missing catalog`() {
let list = LanguageList(
bundle: .main
)
#expect(list.catalogState == .missing)
}
}
@Suite("all")
struct All {
@Test
func `lists the catalog languages`() {
let list = LanguageList(
bundle: .module
)
#expect(list.all.contains("en"))
#expect(list.all.contains("de"))
}
@Test
func `excludes the base localization`() {
let list = LanguageList(
resolver: StubCatalog(
sourceLanguage: "en",
languages: ["Base", "de", "en"]
)
)
#expect(list.all == ["de", "en"])
}
@Test
func `sorts the languages`() {
let list = LanguageList(
resolver: StubCatalog(
sourceLanguage: "en",
languages: ["fr", "en", "de"]
)
)
#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"])
}
}
}
@@ -0,0 +1,29 @@
{
"sourceLanguage" : "en",
"strings" : {
"test.greeting" : {
"comment" : "Fixture string used by the Localization test suite.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hallo"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hello"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Olá"
}
}
}
}
},
"version" : "1.0"
}
@@ -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
}
}
@@ -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 = "Persistence"
BuildableName = "Persistence"
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 = "PersistenceTests"
BuildableName = "PersistenceTests"
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 = "Persistence"
BuildableName = "Persistence"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+99
View File
@@ -0,0 +1,99 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Persistence",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Persistence",
targets: [
"Persistence"
]
)
],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird-fluent.git",
from: "2.0.0"
),
.package(
url: "https://github.com/vapor/fluent-postgres-driver.git",
from: "2.12.0"
),
.package(
url: "https://github.com/vapor/fluent-sqlite-driver.git",
from: "4.9.0"
),
.package(
url: "https://github.com/vapor/sql-kit.git",
from: "3.36.0"
),
.package(
url: "https://github.com/vapor/postgres-nio.git",
from: "1.33.0"
),
.package(
url: "https://github.com/apple/swift-nio.git",
from: "2.81.0"
),
.package(
url: "https://github.com/apple/swift-nio-ssl.git",
from: "2.25.0"
),
],
targets: [
.target(
name: "Persistence",
dependencies: [
.product(
name: "HummingbirdFluent",
package: "hummingbird-fluent"
),
.product(
name: "FluentPostgresDriver",
package: "fluent-postgres-driver"
),
.product(
name: "FluentSQLiteDriver",
package: "fluent-sqlite-driver"
),
.product(
name: "SQLKit",
package: "sql-kit"
),
.product(
name: "PostgresNIO",
package: "postgres-nio"
),
.product(
name: "NIOSSL",
package: "swift-nio-ssl"
),
],
path: "Sources"
),
.testTarget(
name: "PersistenceTests",
dependencies: [
.byName(name: "Persistence"),
.product(
name: "PostgresNIO",
package: "postgres-nio"
),
.product(
name: "NIOCore",
package: "swift-nio"
),
.product(
name: "NIOPosix",
package: "swift-nio"
),
],
path: "Tests"
),
]
)
+56
View File
@@ -0,0 +1,56 @@
# Persistence
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
## Overview
| Role | Types |
| --- | --- |
| Backend selection | `Driver` (`postgres` or `inMemory`), `Configuration` (the PostgreSQL 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` within a deadline |
| 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. The Website service's `ConfigReader+Properties` has 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` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend migrates on startup, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Example`) instead.
- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint.
- **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 instead of each pooled connection getting a 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 driver itself enforces both TLS postures — `prefer` upgrades only when the server advertises TLS and continues in plaintext otherwise, `require` refuses a server that offers none. Tests pin both against a fake plaintext-only server.
## 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 and silent fake PostgreSQL servers, and the suite Tag constants
```
## Testing
The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found:
```sh
swift test # in-memory only
POSTGRES_TEST_HOST=127.0.0.1 swift test # against the local PostgreSQL (make db-mount)
```
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` around `fluent.shutdown()` is the shape to follow.
Every suite carries a tag for the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server.
@@ -0,0 +1,28 @@
import FluentKit
/// Creates and drops the `example_records` table backing ``ExampleRecord``.
///
/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a
/// domain model is defined. Migrations are append-only in production add a new migration to alter the
/// schema rather than editing one that has already run.
struct CreateExampleRecord: AsyncMigration {
// MARK: Methods
/// Creates the `example_records` table with an `id` primary key and a required `name` column.
/// - Parameter database: the database the schema change is applied to.
func prepare(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.id()
.field("name", .string, .required)
.create()
}
/// Drops the `example_records` table, reverting ``prepare(on:)``.
/// - Parameter database: the database the schema change is applied to.
func revert(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.delete()
}
}
@@ -0,0 +1,45 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `example_records` row.
///
/// This is reference scaffolding: it demonstrates the model migration repository pattern the rest of
/// the package is built around, and is what the tests exercise. Replace it with the first real domain model
/// (paired with its own migration and repository) once one is defined.
///
/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never
/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the
/// conformance is declared `@unchecked Sendable`.
final class ExampleRecord: Model, @unchecked Sendable {
// MARK: Properties
/// The name of the backing table.
static let schema = "example_records"
/// The row's primary key, assigned on first save.
@ID(key: .id)
var id: UUID?
/// The row's name column.
@Field(key: "name")
var name: String
// MARK: Initializers
/// Creates an empty record, as required by FluentKit to hydrate query results.
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - id: the primary key, or `nil` to have one assigned on save.
/// - name: the value of the name column.
init(
id: UUID? = nil,
name: String
) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,63 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries.
///
/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference
/// types that must not escape the database's execution context.
public struct Example: Sendable, Equatable {
// MARK: Properties
/// The record's primary key, or `nil` if it has never been saved.
public let id: UUID?
/// The record's name.
public let name: String
}
/// Reads and writes ``ExampleRecord`` rows through the default database.
///
/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the
/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning so no
/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``;
/// replace it with the first real repository once a domain model is defined.
public struct ExampleRepository: Sendable {
// MARK: Properties
/// The service providing the default database the repository reads and writes through.
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(fluent: Fluent) {
self.fluent = fluent
}
// MARK: Methods
/// Inserts a record with the given name.
/// - Parameter name: the name of the record to insert.
/// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier.
public func create(name: String) async throws -> Example {
let record = ExampleRecord(name: name)
try await record.save(on: fluent.db())
return Example(id: record.id, name: record.name)
}
/// Fetches every record, ordered by name.
/// - Returns: a `Sendable` snapshot of each record, sorted by name.
public func all() async throws -> [Example] {
try await ExampleRecord.query(on: fluent.db())
.sort(\.$name)
.all()
.map { Example(id: $0.id, name: $0.name) }
}
}
@@ -0,0 +1,17 @@
/// The persistence backend the service runs against.
///
/// The executable picks a driver at startup and hands it to ``Service``, which registers the matching database as the default one. Repositories resolve
/// that default and stay agnostic of which backend is in use.
public enum Driver: Sendable {
/// A PostgreSQL server, reached with the given connection parameters.
///
/// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the connection is opened with.
case postgres(Configuration)
/// An ephemeral, in-process SQLite database held entirely in memory.
///
/// Nothing is written to disk, and all data is lost when the service stops intended for local development and tests.
case inMemory
}
@@ -0,0 +1,41 @@
import NIOSSL
import PostgresNIO
/// The TLS posture used when connecting to the database.
///
/// The executable derives a posture from its `database.tls` configuration and passes it along as part of ``Configuration``; the PostgreSQL driver
/// receives the resulting connection TLS mode through ``postgresTLS()``.
public enum TLS: Sendable {
/// Connect without TLS, in plaintext.
case off
/// Connect over TLS when the server offers it, falling back to plaintext otherwise.
case prefer
/// Connect only over TLS, refusing the connection when the server offers none.
case require
}
// MARK: - Methods
extension TLS {
/// The connection TLS mode passed to the PostgreSQL driver for this posture.
///
/// Returns `.disable` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``. The driver enforces
/// both semantics natively: `prefer` upgrades to TLS only when the server advertises support and continues in plaintext otherwise, while `require`
/// refuses the connection when the server offers no TLS.
///
/// - Throws: an error when the TLS context cannot be built from the default client configuration.
/// - Returns: the connection TLS mode for this posture.
func postgresTLS() throws -> PostgresConnection.Configuration.TLS {
switch self {
case .off: .disable
case .prefer: .prefer(try NIOSSLContext(configuration: .makeClientConfiguration()))
case .require: .require(try NIOSSLContext(configuration: .makeClientConfiguration()))
}
}
}
@@ -0,0 +1,29 @@
import HummingbirdFluent
/// A registrar declaring every migration against a `Fluent` service.
///
/// Built once around the application's `Fluent` service and called as a function `await migrate()` during startup, before the migrations are
/// applied.
public struct PrepareDB: Sendable {
// MARK: Initializers
/// Creates a registrar for the migrations for a `Fluent` service.
public init() {}
// MARK: Methods
/// Registers every migration against the `Fluent` service, in order.
///
/// This is the single place migrations are declared: add each new migration here, in the order it must run (migrations are applied in registration order
/// and are append-only). Registering does not apply them the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do
/// that.
public func callAsFunction(
for fluent: Fluent
) async {
await fluent.migrations.add([
CreateExampleRecord()
])
}
}
@@ -0,0 +1,77 @@
import HummingbirdFluent
import SQLKit
/// A readiness probe reporting whether the database behind a `Fluent` service is reachable.
///
/// Built once around the application's `Fluent` service and called as a function whenever a fresh answer is needed typically from a readiness endpoint:
/// `let ready = await probe()`.
public struct Probe: Sendable {
// MARK: Properties
/// The `Fluent` service whose default database is probed.
private let fluent: Fluent
/// The longest the probe waits for the database's answer before reporting it as not reachable.
private let timeout: Duration
// MARK: Initializers
/// Creates a probe for the default database of the given `Fluent` service.
/// - Parameters:
/// - fluent: the `Fluent` service whose default database is probed.
/// - timeout: the longest the probe waits for the database's answer before reporting it as not reachable.
public init(
fluent: Fluent,
timeout: Duration = .seconds(2)
) {
self.fluent = fluent
self.timeout = timeout
}
// MARK: Methods
/// Reports whether the database behind the `Fluent` service is reachable.
///
/// Runs a trivial `SELECT 1` against the default database the cheapest statement both the PostgreSQL and SQLite backends understand so
/// a readiness check does not depend on any particular schema or model. Any failure (connection refused, authentication error, pool exhausted) is
/// reported as not reachable rather than thrown, and an answer that does not arrive within the timeout is likewise reported as not reachable so
/// a database that hangs yields a prompt "not ready" instead of a hanging readiness endpoint. A default database that is not an SQL database is
/// also reported as not reachable.
/// - Returns: `true` when the database answers the probe in time, `false` otherwise.
public func callAsFunction() async -> Bool {
guard let database = fluent.db() as? any SQLDatabase else {
return false
}
// The query is raced against the deadline from unstructured tasks whose first answer wins: a structured group
// would await the query child before returning, so a database that hangs mid-query the very failure the
// deadline exists for would hang the probe with it. The loser is cancelled and abandoned; a late answer lands
// in a finished stream and is dropped.
let (answers, continuation) = AsyncStream.makeStream(of: Bool.self)
let query = Task {
do {
try await database
.raw("SELECT 1")
.run()
continuation.yield(true)
} catch {
continuation.yield(false)
}
}
let deadline = Task {
try? await Task.sleep(for: timeout)
continuation.yield(false)
}
var answer = answers.makeAsyncIterator()
let isReachable = await answer.next() ?? false
query.cancel()
deadline.cancel()
return isReachable
}
}
@@ -0,0 +1,99 @@
import FluentPostgresDriver
import FluentSQLiteDriver
import HummingbirdFluent
import Logging
import PostgresNIO
/// A factory building the `Fluent` service the application persists through.
///
/// Built once around the driver the executable picks at startup and called as a function to produce the configured service: `let fluent = service()`.
public struct Service: Sendable {
// MARK: Enumerations
/// The persistence backend resolved at construction, with the PostgreSQL TLS mode already built.
private enum Backend {
case postgres(Configuration, PostgresConnection.Configuration.TLS)
case inMemory
}
// MARK: Properties
/// The resolved persistence backend to register.
private let backend: Backend
/// The logger the database emits through.
private let logger: Logger
// MARK: Initializers
/// Creates a factory for a `Fluent` service backed by the given driver.
///
/// The TLS context for the PostgreSQL backend is built here, once the factory holds only resolved configuration, so producing the
/// service afterwards cannot fail.
/// - Parameters:
/// - driver: the persistence backend to register.
/// - logger: the logger the database emits through.
/// - Throws: an error when the TLS context for the PostgreSQL backend cannot be built.
public init(
driver: Driver,
logger: Logger
) throws {
switch driver {
case .postgres(let configuration):
self.backend = .postgres(
configuration,
try configuration.tls.postgresTLS()
)
case .inMemory:
self.backend = .inMemory
}
self.logger = logger
}
// MARK: Methods
/// Builds a `Fluent` service configured for the driver.
///
/// The selected backend is registered as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which
/// driver is in use. The returned service is not yet running; add it to the application's service group (`app.addServices(_:)`) so it starts and shuts
/// its connection pool down alongside the server.
/// - Returns: the configured `Fluent` service, ready to be added to the service group.
public func callAsFunction() -> Fluent {
let fluent = Fluent(
logger: logger
)
switch backend {
case .postgres(let configuration, let tls):
fluent.databases.use(
.postgres(
configuration: .init(
hostname: configuration.host,
port: configuration.port,
username: configuration.username,
password: configuration.password,
database: configuration.name,
tls: tls
),
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop,
connectionPoolTimeout: .init(configuration.poolTimeout)
),
as: .psql,
isDefault: true
)
case .inMemory:
// A single connection keeps every query pointed at the same in-memory store, rather than each pooled
// connection getting its own private database.
fluent.databases.use(
.sqlite(.memory, maxConnectionsPerEventLoop: 1),
as: .sqlite,
isDefault: true
)
}
return fluent
}
}
@@ -0,0 +1,65 @@
/// The connection parameters for the PostgreSQL backend.
///
/// The executable builds this from its `database.*` configuration; the package itself reads no configuration, so these values arrive as plain data.
public struct Configuration: Sendable {
// MARK: Properties
/// The host the database server is reached at.
let host: String
/// The maximum number of pooled connections opened per event loop.
let maxConnectionsPerEventLoop: Int
/// The name of the database to open.
let name: String
/// The password the connection authenticates with.
let password: String
/// The longest a query waits for a pooled connection to become available before failing.
let poolTimeout: Duration
/// The port the database server listens on.
let port: Int
/// The TLS posture used when connecting.
let tls: TLS
/// The username the connection authenticates as.
let username: String
// MARK: Initializers
/// Creates a set of PostgreSQL connection parameters.
/// - Parameters:
/// - host: the host the database server is reached at.
/// - port: the port the database server listens on.
/// - name: the name of the database to open.
/// - username: the username the connection authenticates as.
/// - password: the password the connection authenticates with.
/// - tls: the TLS posture used when connecting.
/// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop.
/// - poolTimeout: the longest a query waits for a pooled connection to become available before failing.
public init(
host: String,
port: Int,
name: String,
username: String,
password: String,
tls: TLS,
maxConnectionsPerEventLoop: Int,
poolTimeout: Duration
) {
self.host = host
self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop
self.name = name
self.password = password
self.poolTimeout = poolTimeout
self.port = port
self.tls = tls
self.username = username
}
}
@@ -0,0 +1,92 @@
import Logging
import NIOCore
import NIOPosix
import PostgresNIO
import Testing
@testable import Persistence
@Suite(
"TLS enumeration",
.tags(.enumeration)
)
struct TLSTests {
// MARK: Methods tests
@Test
func `off connects in plaintext`() async throws {
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
// which the fake server answers in plaintext.
let server = try await PlaintextPostgresServer.start()
let connection = try await connect(to: server, tls: .off)
let isConnected = !connection.isClosed
try await connection.close()
try await server.stop()
#expect(isConnected)
}
@Test
func `prefer falls back to plaintext when the server offers no TLS`() async throws {
// The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading
// to plaintext pinning the driver behavior the `prefer` posture relies on.
let server = try await PlaintextPostgresServer.start()
let connection = try await connect(to: server, tls: .prefer)
let isConnected = !connection.isClosed
try await connection.close()
try await server.stop()
#expect(isConnected)
}
@Test
func `require refuses the connection when the server offers no TLS`() async throws {
// The fake server refuses the `SSLRequest`, so the driver must fail the connection instead of
// downgrading pinning the refusal the `require` posture promises.
let server = try await PlaintextPostgresServer.start()
let error = await #expect(throws: PSQLError.self) {
_ = try await connect(to: server, tls: .require)
}
try await server.stop()
#expect(error?.code == .sslUnsupported)
}
}
// MARK: - Helpers
private extension TLSTests {
/// Opens a connection to the given fake server with the given TLS posture.
/// - Parameters:
/// - server: the fake server to connect to.
/// - tls: the TLS posture to connect with.
/// - Returns: the open connection, to be closed by the caller.
func connect(
to server: PlaintextPostgresServer,
tls: TLS
) async throws -> PostgresConnection {
try await PostgresConnection.connect(
on: MultiThreadedEventLoopGroup.singleton.any(),
configuration: .init(
host: "127.0.0.1",
port: server.port,
username: "loud",
password: "loud",
database: "loud",
tls: tls.postgresTLS()
),
id: 1,
logger: Logger(label: "test")
)
}
}
@@ -0,0 +1,121 @@
import FluentKit
import HummingbirdFluent
import Logging
import Testing
@testable import Persistence
@Suite(
"Probe method",
.tags(.method)
)
struct ProbeTests {
// MARK: Methods tests
@Test
func `reports a reachable database`() async throws {
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(isReachable)
}
@Test
func `reports an unreachable database`() async throws {
// Port 1 on the loopback interface has nothing listening, so the connection is refused
// immediately instead of timing out.
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: 1,
name: "unreachable",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
@Test
func `reports a hanging database as unreachable within its timeout`() async throws {
// The silent server accepts the TCP connection and never answers, so the probe's query can only
// ever be resolved by its deadline without one, it would wait out the driver's own connect
// timeout (10 seconds) instead.
let server = try await SilentPostgresServer.start()
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: server.port,
name: "hanging",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(
fluent: fluent,
timeout: .milliseconds(100)
)
let clock = ContinuousClock()
let start = clock.now
let isReachable = await probe()
let elapsed = clock.now - start
try await fluent.shutdown()
try await server.stop()
#expect(!isReachable)
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
// 10-second connect timeout only the deadline can answer this fast.
#expect(elapsed < .seconds(5))
}
@Test
func `reports a default database that is not an SQL database`() async throws {
let fluent = Fluent(logger: Logger(label: "test"))
fluent.databases.use(
.init(make: { NotSQLConfiguration() }),
as: .init(string: "not-sql"),
isDefault: true
)
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
}
@@ -0,0 +1,173 @@
import Foundation
import Logging
import SQLKit
import Testing
@testable import Persistence
@Suite(
"Service method",
.tags(.method)
)
struct ServiceTests {
// MARK: Methods tests
@Test
func `registers an SQLite database as the default for the in-memory driver`() async throws {
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "sqlite")
}
@Test
func `registers a PostgreSQL database as the default for the postgres driver`() async throws {
// Resolving the default database opens no connection pooling is lazy so no server
// needs to be listening on the configured host and port.
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: 5432,
name: "site",
username: "site",
password: "site",
tls: .off,
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "postgresql")
}
@Test
func `builds a usable in-memory database`() async throws {
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
do {
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test("in-memory: migrate, insert, read back")
func inMemoryRoundTrip() async throws {
try await roundTrip(driver: .inMemory)
}
@Test(
"postgres: migrate, insert, read back",
.enabled(if: postgresDriver != nil)
)
func postgresRoundTrip() async throws {
try await roundTrip(
driver: postgresDriver!,
revertAfter: true
)
}
}
// MARK: - Helpers
private extension ServiceTests {
/// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after.
///
/// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that,
/// the test must shut it down explicitly even on failure or the pool asserts on `deinit`.
/// - Parameters:
/// - driver: the persistence backend to exercise.
/// - revertAfter: whether to revert the migrations afterwards; set for a shared database (the
/// in-memory database is discarded on shutdown, so it needs no revert).
func roundTrip(
driver: Persistence.Driver,
revertAfter: Bool = false
) async throws {
let prepareDB = PrepareDB()
let service = try Service(
driver: driver,
logger: Logger(label: "test")
)
let fluent = service()
do {
await prepareDB(for: fluent)
try await fluent.migrate()
let repository = ExampleRepository(fluent: fluent)
let created = try await repository.create(name: "site")
#expect(try await repository.all().contains(created))
if revertAfter {
try await fluent.revert()
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
/// The PostgreSQL driver built from the `POSTGRES_TEST_*` environment variables, or `nil` when the gate
/// variable `POSTGRES_TEST_HOST` is unset in which case the PostgreSQL integration test is skipped, so
/// the suite stays runnable with no database available.
private let postgresDriver: Persistence.Driver? = {
let environment = ProcessInfo.processInfo.environment
guard let host = environment["POSTGRES_TEST_HOST"] else {
return nil
}
return .postgres(
.init(
host: host,
port: environment["POSTGRES_TEST_PORT"].flatMap(Int.init) ?? 5432,
name: environment["POSTGRES_TEST_NAME"] ?? "site",
username: environment["POSTGRES_TEST_USERNAME"] ?? "site",
password: environment["POSTGRES_TEST_PASSWORD"] ?? "site",
tls: .off,
maxConnectionsPerEventLoop: 2,
poolTimeout: .seconds(10)
)
)
}()
@@ -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,13 @@
import FluentKit
struct NotSQLConfiguration: DatabaseConfiguration {
var middleware: [any AnyModelMiddleware] = []
func makeDriver(
for databases: Databases
) -> any DatabaseDriver {
NotSQLDriver()
}
}
@@ -0,0 +1,44 @@
import FluentKit
/// A Fluent database that is not an `SQLDatabase`, so the probe's downcast fails.
///
/// Every query succeeds, proving the probe reports "not reachable" because of the failed downcast
/// rather than a failing backend.
struct NotSQLDatabase: Database {
let context: DatabaseContext
var inTransaction: Bool { false }
func execute(
query: DatabaseQuery,
onOutput: @escaping @Sendable (any DatabaseOutput) -> Void
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
schema: DatabaseSchema
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
enum: DatabaseEnum
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func transaction<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
func withConnection<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
}
@@ -0,0 +1,13 @@
import FluentKit
struct NotSQLDriver: DatabaseDriver {
func makeDatabase(
with context: DatabaseContext
) -> any Database {
NotSQLDatabase(context: context)
}
func shutdown() {}
}
@@ -0,0 +1,169 @@
import NIOCore
import NIOPosix
/// A fake PostgreSQL server speaking just enough of the wire protocol to complete a plaintext startup.
///
/// It answers the client's `SSLRequest` with `'N'` (no SSL) and the subsequent startup message with
/// `AuthenticationOk`, `BackendKeyData`, and `ReadyForQuery` 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, and what the `require` test connects to,
/// proving the driver refuses the connection instead of downgrading.
final class PlaintextPostgresServer {
// 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 -> PlaintextPostgresServer {
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 PlaintextPostgresServer {
/// Refuses the client's `SSLRequest`, accepts whatever startup message arrives, and closes on anything
/// after that (e.g. a `Terminate`).
///
/// Unlike MySQL, the PostgreSQL client speaks first, so nothing is written on `channelActive`.
final class Handler: ChannelInboundHandler {
// MARK: Type aliases
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
// MARK: Enumerations
/// The startup phases the connection moves through.
private enum State {
case awaitingSSLRequest
case awaitingStartup
case established
}
// MARK: Constants
/// The magic code identifying an `SSLRequest` message.
private static let sslRequestCode: Int32 = 80877103
// MARK: Properties
/// The startup phase the connection is currently in.
private var state: State = .awaitingSSLRequest
// MARK: Functions
func channelRead(
context: ChannelHandlerContext,
data: NIOAny
) {
let buffer = unwrapInboundIn(data)
switch state {
case .awaitingSSLRequest:
// Peek past the Int32 length at the Int32 code: an `SSLRequest` is refused with a bare
// 'N', while a direct startup message (a client connecting with TLS disabled) is
// answered straight away.
guard buffer.getInteger(at: buffer.readerIndex + 4, as: Int32.self) == Self.sslRequestCode else {
completeStartup(context: context)
return
}
state = .awaitingStartup
var refusal = context.channel.allocator.buffer(capacity: 1)
refusal.writeInteger(UInt8(ascii: "N"))
context.writeAndFlush(
wrapOutboundOut(refusal),
promise: nil
)
case .awaitingStartup:
completeStartup(context: context)
case .established:
context.close(promise: nil)
}
}
/// Answers a startup message and marks the connection established.
private func completeStartup(context: ChannelHandlerContext) {
state = .established
context.writeAndFlush(
wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)),
promise: nil
)
}
// MARK: Helpers
/// The reply completing a plaintext startup: `AuthenticationOk`, `BackendKeyData`, and
/// `ReadyForQuery` in a single flush.
///
/// `BackendKeyData` is not optional filler the client requires it before `ReadyForQuery` by
/// default and fails the connection when it is missing.
private static func startupResponse(allocator: ByteBufferAllocator) -> ByteBuffer {
var buffer = allocator.buffer(capacity: 32)
buffer.writeInteger(UInt8(ascii: "R")) // AuthenticationOk
buffer.writeInteger(Int32(8))
buffer.writeInteger(Int32(0))
buffer.writeInteger(UInt8(ascii: "K")) // BackendKeyData
buffer.writeInteger(Int32(12))
buffer.writeInteger(Int32(1)) // process id
buffer.writeInteger(Int32(0)) // secret key
buffer.writeInteger(UInt8(ascii: "Z")) // ReadyForQuery
buffer.writeInteger(Int32(5))
buffer.writeInteger(UInt8(ascii: "I")) // idle
return buffer
}
}
}
@@ -0,0 +1,54 @@
import NIOCore
import NIOPosix
/// A fake server accepting connections and never answering.
///
/// A client connecting to it completes the TCP handshake and then waits forever for the first protocol byte the shape of a database that hangs rather
/// than refuses. This is what the probe's deadline test connects to, proving the probe answers within its timeout instead of hanging alongside the server.
final class SilentPostgresServer {
// 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 -> SilentPostgresServer {
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
.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()
}
}
+31
View File
@@ -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"
),
]
)
+30
View File
@@ -0,0 +1,30 @@
# 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
| 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 for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling 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)
}
}

Some files were not shown because too many files have changed in this diff Show More