Project updates from Template (#1)
This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam. Reviewed-on: #1 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,25 +1,26 @@
|
||||
# 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.
|
||||
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the platform's services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized responses, and page and asset scaffolding.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `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` |
|
||||
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
|
||||
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
|
||||
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
|
||||
| Responses | `CachedHTMLResponse`, and `LocalizedHTMLCollectionResponse` rendering one of them per catalog language |
|
||||
| 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`.
|
||||
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. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
|
||||
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
|
||||
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
|
||||
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own.
|
||||
- **Method structs.** Single-operation types such as `FingerprintAssets` take lifetime-fixed configuration in `init` and per-call inputs in `callAsFunction`.
|
||||
|
||||
## Layout
|
||||
Sources are split by visibility, then by kind, one type per file:
|
||||
@@ -28,12 +29,12 @@ Sources/
|
||||
├── Public/ public API
|
||||
│ ├── Builders/ RouteCollectionBuilder
|
||||
│ ├── Enumerations/ AssetExtension
|
||||
│ ├── Extensions/ addController, plus the default header names and values
|
||||
│ ├── Extensions/ addController and Analytics.Event tagging, plus the default header names, rate limits, and header values
|
||||
│ ├── Methods/ FingerprintAssets
|
||||
│ ├── Middlewares/ the five HTTP middlewares
|
||||
│ ├── Middlewares/ the seven 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/)
|
||||
│ └── Types/ Analytics, SocialCard, StructuredData — each nesting its own types in a folder of that name
|
||||
└── Internal/
|
||||
├── Extensions/ implementation details (the String separators)
|
||||
└── Types/ implementation details (FNV1aHash)
|
||||
@@ -44,8 +45,8 @@ Tests/
|
||||
```
|
||||
|
||||
## 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.
|
||||
Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, `.type` — declared in `Tests/Utils/Extensions/Tag+Constants.swift`, so a run can be sliced by kind. A new suite takes 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).
|
||||
- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms.
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/// 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 {
|
||||
public enum AssetExtension: String, CaseIterable, 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 JPEG image.
|
||||
case jpg
|
||||
/// A JavaScript file.
|
||||
case js
|
||||
/// An MPEG-4 video.
|
||||
case mp4
|
||||
/// A Portable Network Graphics image.
|
||||
case png
|
||||
/// A Scalable Vector Graphics image.
|
||||
case svg
|
||||
/// A plain text file.
|
||||
case txt
|
||||
/// A web application manifest file.
|
||||
case webmanifest
|
||||
/// A WebP image.
|
||||
case webp
|
||||
/// A Web Open Font Format 2 font.
|
||||
case woff2
|
||||
/// An Extensible Markup Language file.
|
||||
case xml
|
||||
}
|
||||
@@ -30,12 +38,16 @@ public extension AssetExtension {
|
||||
var contentType: String {
|
||||
switch self {
|
||||
case .css: "text/css"
|
||||
case .js: "text/javascript"
|
||||
case .png: "image/png"
|
||||
case .ico: "image/vnd.microsoft.icon"
|
||||
case .jpg: "image/jpeg"
|
||||
case .js: "text/javascript"
|
||||
case .mp4: "video/mp4"
|
||||
case .png: "image/png"
|
||||
case .svg: "image/svg+xml"
|
||||
case .txt: "text/plain"
|
||||
case .webmanifest: "application/manifest+json"
|
||||
case .webp: "image/webp"
|
||||
case .woff2: "font/woff2"
|
||||
case .xml: "application/xml"
|
||||
}
|
||||
}
|
||||
@@ -45,6 +57,10 @@ public extension AssetExtension {
|
||||
switch self {
|
||||
case .css: "css"
|
||||
case .js: "js"
|
||||
case .jpg,
|
||||
.webp: "img"
|
||||
case .mp4: "video"
|
||||
case .woff2: "font"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Elementary
|
||||
|
||||
public extension Analytics.Event {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The event's ``attributes`` as attributes of the tag reporting it.
|
||||
///
|
||||
/// ```swift
|
||||
/// a(.href(url)) { "Listen" }
|
||||
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
|
||||
/// ```
|
||||
///
|
||||
/// - Returns: one HTML attribute per event attribute, applied verbatim.
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
|
||||
attributes.map {
|
||||
.custom(
|
||||
name: $0.name,
|
||||
value: $0.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,8 +5,12 @@ public extension 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-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let robotsTag = Self("X-Robots-Tag")!
|
||||
/// 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")!
|
||||
/// The `X-Forwarded-Proto` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let xForwardedProto = Self("X-Forwarded-Proto")!
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Answers a request forwarded over plain HTTP with `301 Moved Permanently` to the same path on the site's HTTPS origin.
|
||||
///
|
||||
/// Behind a TLS-terminating proxy the server only sees plain HTTP, so the visitor's scheme survives only in the `X-Forwarded-Proto` header the proxy
|
||||
/// sets. Redirecting collapses the `http://` and `https://` copies of every page onto one address — what a search engine consolidates a site's signals
|
||||
/// against.
|
||||
///
|
||||
/// Three deliberate choices:
|
||||
/// - **`301`, not `302`.** A temporary redirect tells a crawler the HTTP address is the canonical one, so the HTTP URLs stay indexed. Browsers cache a
|
||||
/// `301` for a long time, so the target must be settled before enabling this.
|
||||
/// - **The target comes from ``Configuration/origin``, not the request's `Host` header.** A client cannot steer a configured origin, so it can neither
|
||||
/// aim the redirect elsewhere nor poison a shared cache with the result.
|
||||
/// - **`/.well-known/` is exempt.** A certificate authority looks there for an ACME challenge over plain HTTP; redirecting it away breaks renewal, and
|
||||
/// the breakage surfaces only when the certificate expires.
|
||||
///
|
||||
/// - Note: `Context` is the request context the middleware is resolved against.
|
||||
public struct HTTPSRedirectMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The origin the redirects point at, and whether the forwarded-protocol header is trusted.
|
||||
private let configuration: Configuration
|
||||
|
||||
/// Whether the middleware redirects at all, resolved once at initialization.
|
||||
///
|
||||
/// An untrusted header disables it, and so does an origin that is not itself HTTPS: redirecting to a plain-HTTP origin would loop.
|
||||
private let isEnabled: Bool
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an HTTPS-redirect middleware.
|
||||
/// - Parameter configuration: the origin the redirects point at, and whether the forwarded-protocol header is trusted.
|
||||
public init(
|
||||
configuration: Configuration
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.isEnabled = configuration.trustForwardedProto
|
||||
&& configuration.origin.hasPrefix(.httpsPrefix)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension HTTPSRedirectMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Redirects a request forwarded over plain HTTP, and passes every other request down the chain.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the redirect, or the downstream response.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
guard
|
||||
isEnabled,
|
||||
isForwardedOverHTTP(request),
|
||||
!isWellKnown(request)
|
||||
else {
|
||||
return try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
var response = Response(status: .movedPermanently)
|
||||
|
||||
response.headers[.location] = configuration.origin + target(of: request)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HTTPSRedirectMiddleware {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether the proxy reported the visitor's request as plain HTTP.
|
||||
///
|
||||
/// Only the leftmost entry is read: a chain of proxies appends to the header, so that entry is the scheme the visitor used. The comparison is
|
||||
/// case-insensitive, the header carrying a scheme name rather than a fixed-case token.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: `true` when the header's first entry names plain HTTP.
|
||||
func isForwardedOverHTTP(
|
||||
_ request: Request
|
||||
) -> Bool {
|
||||
request.headers[.xForwardedProto]?
|
||||
.split(separator: ",")
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.lowercased() == .httpScheme
|
||||
}
|
||||
|
||||
/// Whether the request addresses the reserved well-known space, which is left on plain HTTP so an ACME challenge stays reachable.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: `true` when the path falls inside the well-known space.
|
||||
func isWellKnown(
|
||||
_ request: Request
|
||||
) -> Bool {
|
||||
request.uri.path.hasPrefix(.wellKnownPrefix)
|
||||
}
|
||||
|
||||
/// The path and query the redirect preserves, so a visitor lands on the address they asked for.
|
||||
///
|
||||
/// Rebuilt from the parsed components rather than the raw request target: a target in absolute form would append a second origin to the first.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: the path, followed by the query when the request carries one.
|
||||
func target(
|
||||
of request: Request
|
||||
) -> String {
|
||||
guard
|
||||
let query = request.uri.query,
|
||||
!query.isEmpty
|
||||
else {
|
||||
return request.uri.path
|
||||
}
|
||||
|
||||
return request.uri.path + .querySeparator + query
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
extension HTTPSRedirectMiddleware {
|
||||
/// The origin an ``HTTPSRedirectMiddleware`` redirects to, and whether it trusts the header telling it to.
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The public origin the redirects point at (scheme and host, no trailing slash).
|
||||
///
|
||||
/// An origin that is not itself HTTPS disables the middleware rather than redirecting into a loop.
|
||||
public let origin: String
|
||||
|
||||
/// Whether the visitor's scheme is read from the `X-Forwarded-Proto` header.
|
||||
///
|
||||
/// This is the middleware's only trigger. Enable it solely behind a reverse proxy that sets the header: on a directly reachable server the header
|
||||
/// is client-supplied, so it stays off by default.
|
||||
public let trustForwardedProto: Bool
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an HTTPS-redirect configuration.
|
||||
/// - Parameters:
|
||||
/// - origin: the public origin the redirects point at (scheme and host, no trailing slash).
|
||||
/// - trustForwardedProto: whether the visitor's scheme is read from the `X-Forwarded-Proto` header. Defaults to `false`.
|
||||
public init(
|
||||
origin: String,
|
||||
trustForwardedProto: Bool = false
|
||||
) {
|
||||
self.origin = origin
|
||||
self.trustForwardedProto = trustForwardedProto
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
/// The forwarded-protocol value naming a request made over plain HTTP.
|
||||
static let httpScheme = "http"
|
||||
/// The scheme prefix a redirect target must carry for the redirect to terminate.
|
||||
static let httpsPrefix = "https://"
|
||||
/// The delimiter placed between a redirect target's path and its query.
|
||||
static let querySeparator = "?"
|
||||
/// The reserved path prefix left on plain HTTP, so an ACME challenge stays reachable.
|
||||
static let wellKnownPrefix = "/.well-known/"
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import Localization
|
||||
|
||||
/// Resolves the visitor's preferred language and records it on the request context.
|
||||
///
|
||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
|
||||
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
|
||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `lang` query parameter, its path, and its
|
||||
/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's
|
||||
/// ``LocalizedRequestContext/language``.
|
||||
///
|
||||
/// The request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its
|
||||
/// content by header.
|
||||
/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under
|
||||
/// a language's prefix — its not-found page — answers in that language. Values naming no supported language are ignored, leaving the header. The
|
||||
/// request is passed through untouched — the path and routing are unaffected.
|
||||
///
|
||||
/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL — in practice, its not-found page.
|
||||
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -49,7 +53,13 @@ extension LocalizationMiddleware: RouterMiddleware {
|
||||
) async throws -> Response {
|
||||
var context = context
|
||||
|
||||
// The deliberate query override first; failing that, the leading path segment, so a language's whole URL
|
||||
// prefix — routed or not — answers in its language.
|
||||
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
|
||||
?? request.uri.path.split(separator: "/").first.map(String.init)
|
||||
|
||||
context.language = negotiate(
|
||||
requested: requested,
|
||||
acceptLanguage: request.headers[.acceptLanguage]
|
||||
)
|
||||
|
||||
@@ -57,3 +67,13 @@ extension LocalizationMiddleware: RouterMiddleware {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension Substring {
|
||||
/// A namespace for the query parameters the middleware reads.
|
||||
enum Parameter {
|
||||
/// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path.
|
||||
static let language: Substring = "lang"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import Hummingbird
|
||||
/// 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.
|
||||
///
|
||||
/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates.
|
||||
/// An unrouted path names no edition, so no canonical URL contradicts the header.
|
||||
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -27,6 +30,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
self.responses = .init(
|
||||
bundle: bundle,
|
||||
status: .notFound,
|
||||
variesOnAcceptLanguage: true,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Answers a request whose path carries a trailing slash with `301 Moved Permanently` to the same path without one.
|
||||
///
|
||||
/// The router matches `/page` and `/page/` alike, and `FileMiddleware` serves `/robots.txt/` as readily as `/robots.txt`, so without this
|
||||
/// every address on the site answers under at least two URLs. A search engine treats those as separate pages competing with each other, and any link
|
||||
/// earned by one is not credited to the other. Redirecting collapses them onto the form the pages already name as canonical.
|
||||
///
|
||||
/// Two deliberate choices:
|
||||
/// - **The `Location` is relative.** A relative target resolves against the request's own scheme and host, so the middleware needs no configured
|
||||
/// origin and cannot be aimed elsewhere by a forged `Host` header.
|
||||
/// - **Only `GET` and `HEAD` are redirected.** A client answering a `301` to a `POST` may repeat it as a `GET` and drop the body, so a form
|
||||
/// submission is left to the route that already matches it.
|
||||
///
|
||||
/// - Note: `Context` is the request context the middleware is resolved against.
|
||||
public struct TrailingSlashRedirectMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a trailing-slash redirect middleware.
|
||||
public init() {}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension TrailingSlashRedirectMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Redirects a `GET` or `HEAD` whose path carries a trailing slash, and passes every other request down the chain.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the redirect, or the downstream response.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let path = request.uri.path
|
||||
let canonical = canonicalPath(of: path)
|
||||
|
||||
guard
|
||||
request.method == .get || request.method == .head,
|
||||
canonical != path
|
||||
else {
|
||||
return try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
var response = Response(status: .movedPermanently)
|
||||
|
||||
response.headers[.location] = canonical + query(of: request)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TrailingSlashRedirectMiddleware {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The path with its trailing slashes removed, which is the form the pages name as their canonical URL.
|
||||
///
|
||||
/// A path of nothing but slashes collapses to the root, so `//` redirects to `/` while `/` itself is left alone.
|
||||
/// - Parameter path: the requested path.
|
||||
/// - Returns: the canonical form of the path.
|
||||
func canonicalPath(
|
||||
of path: String
|
||||
) -> String {
|
||||
var canonical = path
|
||||
|
||||
while canonical.hasSuffix(.pathSeparator), canonical != .pathSeparator {
|
||||
canonical.removeLast()
|
||||
}
|
||||
|
||||
return canonical
|
||||
}
|
||||
|
||||
/// The query the redirect preserves, so a campaign-tagged link survives the canonicalization.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: the query prefixed with its delimiter, or an empty string when the request carries none.
|
||||
func query(
|
||||
of request: Request
|
||||
) -> String {
|
||||
guard
|
||||
let query = request.uri.query,
|
||||
!query.isEmpty
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return .querySeparator + query
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
/// The separator a canonical path never ends on, and the root path it collapses to.
|
||||
static let pathSeparator = "/"
|
||||
/// The delimiter placed between a redirect target's path and its query.
|
||||
static let querySeparator = "?"
|
||||
}
|
||||
+16
-6
@@ -7,8 +7,12 @@ 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.
|
||||
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header naming the
|
||||
/// language it was rendered in.
|
||||
///
|
||||
/// `Vary: Accept-Language` is the caller's to declare, since only the caller knows how it picks the language: a responder that negotiates the
|
||||
/// header sets it, so shared caches key on the language rather than serving one to everyone; a responder that pins the language by route does
|
||||
/// not, since the header would announce a negotiation that never happens.
|
||||
public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -25,21 +29,27 @@ public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||
/// - 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`.
|
||||
/// - variesOnAcceptLanguage: whether the responses declare `Vary: Accept-Language`. Defaults to `false`; a responder that
|
||||
/// negotiates the header passes `true`.
|
||||
/// - document: builds the document to render for a given locale.
|
||||
public init<Document: HTMLDocument>(
|
||||
bundle: Bundle,
|
||||
status: HTTPResponse.Status = .ok,
|
||||
variesOnAcceptLanguage: Bool = false,
|
||||
document: (Locale) -> Document
|
||||
) {
|
||||
self.list = .init(bundle: bundle)
|
||||
self.responses = list.all
|
||||
.reduce(into: [:]) { responses, language in
|
||||
var headers: HTTPFields = [.contentLanguage: language]
|
||||
|
||||
if variesOnAcceptLanguage {
|
||||
headers[.vary] = "Accept-Language"
|
||||
}
|
||||
|
||||
responses[language] = CachedHTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [
|
||||
.contentLanguage: language,
|
||||
.vary: "Accept-Language",
|
||||
],
|
||||
additionalHeaders: headers,
|
||||
document: document(.init(
|
||||
identifier: language
|
||||
))
|
||||
|
||||
@@ -60,11 +60,10 @@ public struct Analytics: Sendable {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
|
||||
/// The tracker script's attributes: the website id, the reporting domains when set, 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)
|
||||
.init("data-website-id", value: websiteID)
|
||||
]
|
||||
|
||||
if !domains.isEmpty {
|
||||
|
||||
@@ -7,6 +7,9 @@ public struct SocialCard: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The locales of the card's other language editions, in Open Graph's `language_TERRITORY` form; empty to omit their tags.
|
||||
public let alternateLocales: [String]
|
||||
|
||||
/// The card's share image, or `nil` to omit its tags.
|
||||
public let image: Image?
|
||||
|
||||
@@ -43,6 +46,7 @@ public struct SocialCard: Sendable {
|
||||
/// - 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.
|
||||
/// - alternateLocales: the locales the card's page is also published in, in the same form; empty (the default) to omit their tags.
|
||||
/// - 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``.
|
||||
@@ -52,10 +56,12 @@ public struct SocialCard: Sendable {
|
||||
url: String? = nil,
|
||||
siteName: String? = nil,
|
||||
locale: String? = nil,
|
||||
alternateLocales: [String] = [],
|
||||
image: Image? = nil,
|
||||
type: String = "website",
|
||||
style: Style = .summaryLargeImage
|
||||
) {
|
||||
self.alternateLocales = alternateLocales
|
||||
self.image = image
|
||||
self.locale = locale
|
||||
self.siteName = siteName
|
||||
@@ -68,8 +74,8 @@ public struct SocialCard: Sendable {
|
||||
|
||||
// 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.
|
||||
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, locale, and alternate locales, 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),
|
||||
@@ -78,7 +84,7 @@ public struct SocialCard: Sendable {
|
||||
summary.map { Tag($0, name: .description) },
|
||||
url.map { Tag($0, name: .url) },
|
||||
locale.map { Tag($0, name: .locale) },
|
||||
] + (image?.tags ?? []) + [
|
||||
] + alternateLocales.map { Tag($0, name: .localeAlternate) } + (image?.tags ?? []) + [
|
||||
Tag(style.rawValue, name: .twitter),
|
||||
]
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ extension SocialCard.Tag {
|
||||
case imageWidth = "og:image:width"
|
||||
/// The `og:locale` tag, carrying the locale of the card's text.
|
||||
case locale = "og:locale"
|
||||
/// The `og:locale:alternate` tag, carrying the locale of one other language edition of the card's page; repeated once per edition.
|
||||
case localeAlternate = "og:locale:alternate"
|
||||
/// 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.
|
||||
|
||||
@@ -23,6 +23,20 @@ public struct StructuredData: Equatable, Sendable {
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The `@id` of the `Organization` node the site-wide initializer builds.
|
||||
///
|
||||
/// A page asserting a relationship to it — a `Person`'s `worksFor`, say — references this instead of spelling the fragment again, so the two
|
||||
/// cannot drift.
|
||||
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
|
||||
/// - Returns: the organization node's `@id`.
|
||||
public static func organizationID(
|
||||
forSiteURL url: String
|
||||
) -> String {
|
||||
url + "#organization"
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||
@@ -46,21 +60,49 @@ public extension StructuredData {
|
||||
/// - Parameters:
|
||||
/// - name: the name of the organization and the site.
|
||||
/// - url: the absolute URL the site is served at.
|
||||
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
|
||||
/// - description: what the organization does, or `nil` (the default) to omit its property.
|
||||
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
|
||||
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
|
||||
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
|
||||
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
|
||||
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
|
||||
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
|
||||
init(
|
||||
name: String,
|
||||
url: String,
|
||||
alternateName: String? = nil,
|
||||
description: String? = nil,
|
||||
areaServed: String? = nil,
|
||||
email: String? = nil,
|
||||
logo: String? = nil,
|
||||
profiles: [String] = []
|
||||
inLanguage: [String] = [],
|
||||
profiles: [String] = [],
|
||||
founder: String? = nil
|
||||
) {
|
||||
let id = url + "#organization"
|
||||
let id = Self.organizationID(forSiteURL: url)
|
||||
|
||||
var organization: [Property] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
]
|
||||
|
||||
if let alternateName {
|
||||
organization.append(.init(.alternateName, value: .string(alternateName)))
|
||||
}
|
||||
|
||||
if let description {
|
||||
organization.append(.init(.description, value: .string(description)))
|
||||
}
|
||||
|
||||
if let areaServed {
|
||||
organization.append(.init(.areaServed, value: .string(areaServed)))
|
||||
}
|
||||
|
||||
if let email {
|
||||
organization.append(.init(.email, value: .string(email)))
|
||||
}
|
||||
|
||||
if let logo {
|
||||
organization.append(.init(.logo, value:.string(logo)))
|
||||
}
|
||||
@@ -72,6 +114,23 @@ public extension StructuredData {
|
||||
))
|
||||
}
|
||||
|
||||
if let founder {
|
||||
organization.append(.init(.founder, value: .reference(founder)))
|
||||
}
|
||||
|
||||
var website: [Property] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
.init(.publisher, value: .reference(id)),
|
||||
]
|
||||
|
||||
if !inLanguage.isEmpty {
|
||||
website.append(.init(
|
||||
.inLanguage,
|
||||
value: .array(inLanguage.map(Value.string))
|
||||
))
|
||||
}
|
||||
|
||||
self.init(nodes: [
|
||||
.init(
|
||||
type: .organization,
|
||||
@@ -80,11 +139,7 @@ public extension StructuredData {
|
||||
),
|
||||
.init(
|
||||
type: .website,
|
||||
properties: [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
.init(.publisher, value: .reference(id)),
|
||||
]
|
||||
properties: website
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
+12
@@ -69,6 +69,18 @@ extension StructuredData.Property {
|
||||
// MARK: - Constants
|
||||
|
||||
public extension StructuredData.Property.Name {
|
||||
/// The name the thing a node describes is also known by.
|
||||
static let alternateName: Self = "alternateName"
|
||||
/// The area an organization serves.
|
||||
static let areaServed: Self = "areaServed"
|
||||
/// What the thing a node describes is or does.
|
||||
static let description: Self = "description"
|
||||
/// The address the thing a node describes is written to.
|
||||
static let email: Self = "email"
|
||||
/// The person who founded an organization.
|
||||
static let founder: Self = "founder"
|
||||
/// The language a creative work — a site, a page — is published in.
|
||||
static let inLanguage: Self = "inLanguage"
|
||||
/// The absolute URL of an organization's logo.
|
||||
static let logo: Self = "logo"
|
||||
/// The name of the thing a node describes.
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
extension StructuredData {
|
||||
/// A value of a ``Property``: a string, a list, a nested node, or a reference to another node.
|
||||
/// A value of a ``Property``: a string, a number, 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.
|
||||
@@ -8,6 +8,8 @@ extension StructuredData {
|
||||
case array([Value])
|
||||
/// A nested node, e.g. the place a schema.org event is located at.
|
||||
case node(Node)
|
||||
/// A whole number, e.g. a list item's position, rendered unquoted so it reads as a number rather than as text.
|
||||
case number(Int)
|
||||
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
|
||||
case reference(String)
|
||||
/// A string value.
|
||||
@@ -28,6 +30,8 @@ extension StructuredData.Value {
|
||||
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
|
||||
case .node(let node):
|
||||
node.fragment
|
||||
case .number(let number):
|
||||
String(number)
|
||||
case .reference(let id):
|
||||
#"{"@id":\#(Self.literal(id))}"#
|
||||
case .string(let string):
|
||||
|
||||
@@ -32,6 +32,17 @@ struct AssetExtensionTests {
|
||||
#expect(fileExtension.folder == folder)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `covers every case in the parameterised tests`() {
|
||||
// `zip` stops at the shorter sequence, so a case missing from the arrays below is silently untested rather
|
||||
// than failing — this is what catches that.
|
||||
#expect(Self.extensions == AssetExtension.allCases)
|
||||
#expect(Self.contentTypes.count == AssetExtension.allCases.count)
|
||||
#expect(Self.folders.count == AssetExtension.allCases.count)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@@ -42,32 +53,44 @@ private extension AssetExtensionTests {
|
||||
|
||||
static let extensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js,
|
||||
.png,
|
||||
.ico,
|
||||
.jpg,
|
||||
.js,
|
||||
.mp4,
|
||||
.png,
|
||||
.svg,
|
||||
.txt,
|
||||
.webmanifest,
|
||||
.webp,
|
||||
.woff2,
|
||||
.xml
|
||||
]
|
||||
static let contentTypes: [String] = [
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"image/png",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/jpeg",
|
||||
"text/javascript",
|
||||
"video/mp4",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"text/plain",
|
||||
"application/manifest+json",
|
||||
"image/webp",
|
||||
"font/woff2",
|
||||
"application/xml"
|
||||
]
|
||||
static let folders: [String?] = [
|
||||
"css",
|
||||
nil,
|
||||
"img",
|
||||
"js",
|
||||
"video",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
"img",
|
||||
"font",
|
||||
nil
|
||||
]
|
||||
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"Analytics.Event+Tagging extension",
|
||||
.tags(.extension)
|
||||
)
|
||||
struct AnalyticsEventTaggingTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `applies the event name to the tag`() {
|
||||
let markup = a(.href("/")) { "Listen" }
|
||||
.attributes(contentsOf: Analytics.Event(name: "instagram").tagging())
|
||||
|
||||
#expect(markup.render().contains(#"data-umami-event="instagram""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies every event attribute to the tag`() {
|
||||
let event = Analytics.Event(
|
||||
name: "playlist",
|
||||
properties: ["set": "avc-xi"]
|
||||
)
|
||||
|
||||
let markup = button {}
|
||||
.attributes(contentsOf: event.tagging())
|
||||
let rendered = markup.render()
|
||||
|
||||
#expect(rendered.contains(#"data-umami-event="playlist""#))
|
||||
#expect(rendered.contains(#"data-umami-event-set="avc-xi""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns one attribute per event attribute`() {
|
||||
let event = Analytics.Event(
|
||||
name: "playlist",
|
||||
properties: ["set": "avc-xi"]
|
||||
)
|
||||
|
||||
let attributes: [HTMLAttribute<HTMLTag.a>] = event.tagging()
|
||||
|
||||
#expect(attributes.count == event.attributes.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies the event name to an SVG tag`() {
|
||||
// `SVGTag.path` is no `HTMLTrait.Attributes.Global`, so this stops compiling if the method narrows back to HTML tags.
|
||||
let rendered = SVG.path {}
|
||||
.attributes(contentsOf: Analytics.Event(name: "logo").tagging())
|
||||
.render()
|
||||
|
||||
#expect(rendered.contains(#"data-umami-event="logo""#))
|
||||
}
|
||||
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"HTTPSRedirectMiddleware middleware",
|
||||
.tags(.middleware)
|
||||
)
|
||||
struct HTTPSRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `redirects a request forwarded over plain http`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "https://example.com/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves the path and the query of the redirected request`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello?utm_source=test&utm_medium=email",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "https://example.com/hello?utm_source=test&utm_medium=email")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches the forwarded scheme regardless of its casing`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "HTTP"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reads the leftmost entry of a proxy chain`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http, https"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a request forwarded over https through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "https"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a request without the forwarded header through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes every request through when the header is not trusted`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
origin: "https://example.com",
|
||||
trustForwardedProto: false
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves the well-known space on plain http`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/.well-known/acme-challenge/token",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes every request through when the origin is not itself https`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
origin: "http://example.com",
|
||||
trustForwardedProto: true
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HTTPSRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the HTTPS-redirect middleware ahead of a `/hello`
|
||||
/// route returning a plain body and a `/.well-known/acme-challenge/token` route standing in for
|
||||
/// a certificate authority's challenge file.
|
||||
func app(
|
||||
configuration: HTTPSRedirectMiddleware<BasicRequestContext>.Configuration = .init(
|
||||
origin: "https://example.com",
|
||||
trustForwardedProto: true
|
||||
)
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
HTTPSRedirectMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get(".well-known/acme-challenge/token") { _, _ in
|
||||
"token"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -25,6 +25,9 @@ struct LocalizationMiddlewareTests {
|
||||
router.get("language") { _, context in
|
||||
context.language
|
||||
}
|
||||
router.get("de/language") { _, context in
|
||||
context.language
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
@@ -69,4 +72,44 @@ struct LocalizationMiddlewareTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `honours a supported lang query parameter over the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language?lang=de",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "en"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A language's whole URL prefix answers in its language, so its not-found page does too.
|
||||
@Test
|
||||
func `pins the language a leading path segment names over the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/de/language",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "en"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `negotiates the header when the lang query parameter is unsupported`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language?lang=fr",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "de"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"TrailingSlashRedirectMiddleware middleware",
|
||||
.tags(.middleware)
|
||||
)
|
||||
struct TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `redirects a path carrying a trailing slash`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves the query of the redirected request`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/?utm_source=test&utm_medium=email",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello?utm_source=test&utm_medium=email")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `collapses a path of nothing but slashes onto the root`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "//",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strips every trailing slash at once`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello///",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes the canonical path through`() 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[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves the root path alone`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `redirects a head request as it does a get`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a post through so its body survives`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .post
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the trailing-slash middleware ahead of a `/hello`
|
||||
/// route answering both `GET` and `POST`, and a root route standing in for the landing page.
|
||||
func app() -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
TrailingSlashRedirectMiddleware()
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.post("hello") { _, _ in
|
||||
"Posted!"
|
||||
}
|
||||
|
||||
router.get("/") { _, _ in
|
||||
"Root!"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,7 @@ struct SocialCardTests {
|
||||
url: "https://site.example/",
|
||||
siteName: "A Site",
|
||||
locale: "en",
|
||||
alternateLocales: ["nl_NL", "de_DE"],
|
||||
image: .init(
|
||||
url: "https://site.example/img/card.png",
|
||||
width: 2400,
|
||||
@@ -33,6 +34,8 @@ struct SocialCardTests {
|
||||
.init("A summary.", name: .description),
|
||||
.init("https://site.example/", name: .url),
|
||||
.init("en", name: .locale),
|
||||
.init("nl_NL", name: .localeAlternate),
|
||||
.init("de_DE", name: .localeAlternate),
|
||||
.init("https://site.example/img/card.png", name: .image),
|
||||
.init("2400", name: .imageWidth),
|
||||
.init("1260", name: .imageHeight),
|
||||
|
||||
@@ -17,6 +17,7 @@ struct StructuredDataTests {
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
logo: "https://site.example/logo.png",
|
||||
inLanguage: ["en", "nl"],
|
||||
profiles: [
|
||||
"https://social.example/a-site",
|
||||
"https://videos.example/a-site",
|
||||
@@ -26,10 +27,64 @@ struct StructuredDataTests {
|
||||
#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"}}]}"#
|
||||
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `carries the organization's alternate name, description, area, and address in that order`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
alternateName: "The Site",
|
||||
description: "What the site is.",
|
||||
areaServed: "A City",
|
||||
email: "hello@site.example"
|
||||
)
|
||||
|
||||
#expect(data.payload.contains(
|
||||
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","description":"What the site is.","areaServed":"A City","email":"hello@site.example""#
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `references the organization's founder by identifier`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
profiles: ["https://social.example/a-site"],
|
||||
founder: "https://site.example/who#person"
|
||||
)
|
||||
|
||||
// Last of the organization's properties, after the profiles.
|
||||
#expect(data.payload.contains(
|
||||
#""sameAs":["https://social.example/a-site"],"founder":{"@id":"https://site.example/who#person"}}"#
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the founder when the data carries none`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
)
|
||||
|
||||
#expect(!data.payload.contains("founder"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `derives the organization's identifier from the site URL`() {
|
||||
let url = "https://site.example/"
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: url
|
||||
)
|
||||
|
||||
// Another page references the organization by this, so it must name the node the initializer actually builds.
|
||||
#expect(StructuredData.organizationID(forSiteURL: url) == "https://site.example/#organization")
|
||||
#expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the properties of the facts minimal data does not carry`() {
|
||||
let data = StructuredData(
|
||||
@@ -120,6 +175,9 @@ struct StructuredDataTests {
|
||||
#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"}"#)
|
||||
// Unquoted, or a consumer reads the position of a list item as text and cannot order by it.
|
||||
#expect(StructuredData.Value.number(1).fragment == "1")
|
||||
#expect(StructuredData.Value.number(-3).fragment == "-3")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Localization
|
||||
The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||
The server-side localization toolkit the platform's services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
|
||||
| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 |
|
||||
| Negotiation | `Negotiate`, which picks the best supported language from an explicit request or an `Accept-Language` header per RFC 9110 |
|
||||
| Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines |
|
||||
| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
|
||||
|
||||
|
||||
@@ -24,18 +24,32 @@ public struct Negotiate: Sendable {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Picks the best supported language for the given `Accept-Language` header value.
|
||||
/// Picks the best supported language for the given explicit request and `Accept-Language` header value.
|
||||
///
|
||||
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
|
||||
/// A `requested` language — a deliberate choice, such as a language switcher's query parameter — wins over the header when it matches a
|
||||
/// supported language, on the same terms a header tag matches; an unsupported value is ignored, so it cannot select a language the catalog
|
||||
/// does not serve.
|
||||
///
|
||||
/// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight
|
||||
/// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the
|
||||
/// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the
|
||||
/// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned.
|
||||
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
|
||||
/// - Parameters:
|
||||
/// - requested: an explicitly requested language identifier, if any; it overrides the header when supported.
|
||||
/// - acceptLanguage: the raw `Accept-Language` header value, if any.
|
||||
/// - Returns: the identifier of the supported language to serve.
|
||||
public func callAsFunction(
|
||||
requested: String? = nil,
|
||||
acceptLanguage language: String?
|
||||
) -> String {
|
||||
if
|
||||
let requested,
|
||||
let match = match(requested, in: list.all)
|
||||
{
|
||||
return match
|
||||
}
|
||||
|
||||
guard let language else {
|
||||
return list.default
|
||||
}
|
||||
|
||||
@@ -127,4 +127,44 @@ struct NegotiateTests {
|
||||
#expect(language == "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `honours a supported requested language over the header`() {
|
||||
let language = negotiate(
|
||||
requested: "de",
|
||||
acceptLanguage: "en"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches a regional requested language by its primary subtag`() {
|
||||
let language = negotiate(
|
||||
requested: "de-AT",
|
||||
acceptLanguage: "en"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores an unsupported requested language`() {
|
||||
let language = negotiate(
|
||||
requested: "fr",
|
||||
acceptLanguage: "de"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores an empty requested language`() {
|
||||
let language = negotiate(
|
||||
requested: "",
|
||||
acceptLanguage: "de"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Persistence
|
||||
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the platform's services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
@@ -8,7 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
||||
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||
| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||
| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||
|
||||
## Design rules
|
||||
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping.
|
||||
@@ -39,6 +39,8 @@ Tests/
|
||||
plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants
|
||||
```
|
||||
|
||||
> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them.
|
||||
|
||||
## Testing
|
||||
The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found:
|
||||
```sh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Utility
|
||||
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
The general-purpose helpers the platform's services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
|
||||
Reference in New Issue
Block a user