Initial commit.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Infrastructure"
|
||||
BuildableName = "Infrastructure"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "InfrastructureTests"
|
||||
BuildableName = "InfrastructureTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Infrastructure"
|
||||
BuildableName = "Infrastructure"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,68 @@
|
||||
// swift-tools-version: 6.3
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Infrastructure",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Infrastructure",
|
||||
targets: [
|
||||
"Infrastructure"
|
||||
]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(
|
||||
path: "../Localization"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/elementary-swift/elementary.git",
|
||||
from: "0.6.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/hummingbird-project/hummingbird.git",
|
||||
from: "2.25.0"
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Infrastructure",
|
||||
dependencies: [
|
||||
.byName(name: "Localization"),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
package: "elementary"
|
||||
),
|
||||
.product(
|
||||
name: "Hummingbird",
|
||||
package: "hummingbird"
|
||||
),
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "InfrastructureTests",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
package: "elementary"
|
||||
),
|
||||
.product(
|
||||
name: "HummingbirdTesting",
|
||||
package: "hummingbird"
|
||||
),
|
||||
],
|
||||
path: "Tests",
|
||||
resources: [
|
||||
// Copied verbatim rather than processed: the String Catalog is read as raw JSON at
|
||||
// runtime so it resolves identically on Darwin and Linux (which cannot compile it).
|
||||
.copy("Catalogs/Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+75
@@ -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"
|
||||
}
|
||||
+82
@@ -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
|
||||
]
|
||||
|
||||
}
|
||||
+159
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+72
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+173
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+159
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user