Project updates from Template

This commit contains the latest updates from the generic Website template, which rework the compression and localization:

- Reworked the compression and localization in the Infrastructure package. (3c568e4)
- Adopted the reworked compression and localization in the Website service. (08cf3e3)

The template commit that only touched the root README (53676ed) was left out, as this project no longer carries that file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:45:58 +02:00
co-authored by Claude Fable 5.1
parent 65b62681eb
commit 916df7e2f0
24 changed files with 426 additions and 218 deletions
+16
View File
@@ -19,6 +19,10 @@ let package = Package(
.package(
path: "../Localization"
),
.package(
url: "https://github.com/adam-fowler/compress-nio.git",
from: "1.4.2"
),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -27,12 +31,20 @@ let package = Package(
url: "https://github.com/hummingbird-project/hummingbird.git",
from: "2.25.0"
),
.package(
url: "https://github.com/hummingbird-project/hummingbird-compression.git",
from: "2.0.0"
),
],
targets: [
.target(
name: "Infrastructure",
dependencies: [
.byName(name: "Localization"),
.product(
name: "CompressNIO",
package: "compress-nio"
),
.product(
name: "Elementary",
package: "elementary"
@@ -41,6 +53,10 @@ let package = Package(
name: "Hummingbird",
package: "hummingbird"
),
.product(
name: "HummingbirdCompression",
package: "hummingbird-compression"
),
],
path: "Sources"
),
+9 -6
View File
@@ -1,17 +1,17 @@
# Infrastructure
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.
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the platform's services build on: declarative routing, hardened HTTP middlewares, pre-rendered and pre-compressed localized responses, and page and asset scaffolding.
## Overview
| Role | Types |
| --- | --- |
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` |
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| 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` |
| Responses | `CachedHTMLResponse` (rendered and gzipped once), `LocalizedHTMLCollectionResponse` (one of those per language) |
| Localization | The `Negotiate` extension resolving a request's language from its `lang` override, path prefix, and `Accept-Language` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
## Design rules
@@ -21,6 +21,8 @@ The package holds only what every service can reuse. Anything a service owns is
- **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`.
- **Wrap upstream, do not restate it.** Where a Hummingbird middleware is almost right, the package delegates to it and adds only the missing decision — `CompressionMiddleware` passes everything to `ResponseCompressionMiddleware` except responses that already name an encoding, which it would otherwise compress twice.
- **Ask where the answer is used.** Work every request pays for must be work every request needs. The request's language is negotiated by the responders that read it, not stamped onto a context on the way past.
## Layout
Sources are split by visibility, then by kind, one type per file:
@@ -29,10 +31,10 @@ Sources/
├── Public/ public API
│ ├── Builders/ RouteCollectionBuilder
│ ├── Enumerations/ AssetExtension
│ ├── Extensions/ addController and Analytics.Event tagging, plus the default header names, rate limits, and header values
│ ├── Extensions/ addController, Analytics.Event tagging, and the request-language negotiation, plus the default header names, rate limits, and header values
│ ├── Methods/ FingerprintAssets
│ ├── Middlewares/ the seven HTTP middlewares
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
│ ├── Protocols/ Asset, Page, RouterController
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
│ └── Types/ Analytics, SocialCard, StructuredData — each nesting its own types in a folder of that name
└── Internal/
@@ -50,3 +52,4 @@ Every suite carries a tag for the kind of API it exercises — `.asset`, `.exten
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms.
- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`.
@@ -0,0 +1,41 @@
import Foundation
import HTTPTypes
import Hummingbird
import Localization
public extension Negotiate {
// MARK: Methods
/// The language to serve a request in.
///
/// The `lang` query parameter wins as a deliberate override; failing that, a leading path segment naming a supported language pins it, so an
/// unrouted path under a language's prefix answers in that language. Anything naming no supported language is ignored, leaving the
/// `Accept-Language` header and its fallback to the default.
///
/// Called where the answer is used rather than stamped onto every request on the way past: page routes that pin their language by URL never ask.
/// - Parameter request: the request to negotiate for.
/// - Returns: the identifier of the supported language to serve.
func callAsFunction(
for request: Request
) -> String {
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
?? request.uri.path.split(separator: "/").first.map(String.init)
return callAsFunction(
requested: requested,
acceptLanguage: request.headers[.acceptLanguage]
)
}
}
// MARK: - Constants
private extension Substring {
/// A namespace for the query parameters the negotiation 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,9 +7,11 @@ extension String {
/// 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'"
/// base URL (`base-uri 'self'`), holds form submissions to the origin (`form-action 'self'`), and forbids framing
/// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link external stylesheets.
///
/// `form-action` is named outright because it inherits from nothing: `default-src` does not cover it, however tight.
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; form-action '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).
@@ -0,0 +1,70 @@
import HTTPTypes
import Hummingbird
import HummingbirdCompression
import Logging
/// Compresses the responses that are not already encoded, and passes the ones that are through untouched.
///
/// Hummingbird's `ResponseCompressionMiddleware` appends to `Content-Encoding` without checking for one, so a pre-compressed page (see
/// ``CachedHTMLResponse``) would go out as `gzip, gzip` a client decodes once and renders the inner gzip stream. This stands in for that
/// middleware and delegates to it, so the threshold, the negotiation and the compressor stay its behaviour.
///
/// - Note: `Context` is the request context the middleware is resolved against.
public struct CompressionMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The middleware the unencoded responses are handed to.
private let compression: ResponseCompressionMiddleware<Context>
// MARK: Initializers
/// Creates a compression middleware.
/// - Parameter minimumResponseSizeToCompress: the smallest response body, in bytes, that is compressed at all.
public init(
minimumResponseSizeToCompress: Int
) {
self.compression = .init(
minimumResponseSizeToCompress: minimumResponseSizeToCompress
)
}
}
// MARK: - RouterMiddleware
extension CompressionMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and compresses the response on the way back up, unless it already names an encoding.
/// - 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, compressed when it was not already.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
let response = try await next(
request,
context
)
guard response.headers[.contentEncoding] == nil else {
return response
}
// The response is already in hand, so the delegate gets it rather than the chain `next` runs once.
return try await compression.handle(
request,
context: context
) { _, _ in
response
}
}
}
@@ -1,79 +0,0 @@
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 `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 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
/// 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
// 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]
)
return try await next(request, context)
}
}
// 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"
}
}
@@ -1,19 +1,23 @@
import Elementary
import Foundation
import Hummingbird
import Localization
/// 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.
/// path and responds with the rendered error page and a `404 Not Found` status.
///
/// 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> {
/// It negotiates the language itself, on the way out, so the cost falls on the 404s rather than on every request through the chain where page
/// routes pin their language by URL, this is the one responder that has to ask. Its responses declare `Vary: Accept-Language` accordingly: an
/// unrouted path names no edition, so no canonical URL contradicts the header.
public struct NotFoundMiddleware<Context: RequestContext> {
// MARK: Properties
/// Negotiates the language a not-found response is served in.
private let negotiate: Negotiate
/// The error page, rendered once per supported language and reused for every not-found response.
private let responses: LocalizedHTMLCollectionResponse
@@ -27,6 +31,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
bundle: Bundle,
document: (Locale) -> Document
) {
self.negotiate = .init(bundle: bundle)
self.responses = .init(
bundle: bundle,
status: .notFound,
@@ -69,7 +74,7 @@ extension NotFoundMiddleware: RouterMiddleware {
}
return responses.response(
for: context.language,
for: negotiate(for: request),
request: request
)
}
@@ -1,14 +0,0 @@
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 }
}
@@ -1,4 +1,6 @@
import CompressNIO
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
import NIOCore
@@ -9,14 +11,14 @@ import NIOCore
/// 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.
///
/// The bytes are gzipped once as well, so a page is compressed at startup rather than per request, and served sized rather than chunked. A client that
/// accepts no gzip gets the rendered bytes, leaving ``CompressionMiddleware`` to apply whatever encoding it did negotiate.
///
/// 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.
/// changes the tag, propagating immediately. The tag is weak and spans both encodings, so a revalidation succeeds whichever copy the client holds.
///
/// ``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
@@ -27,6 +29,9 @@ public struct CachedHTMLResponse: Sendable {
/// The weak entity tag of the rendered bytes, present on successful pages only.
private let eTag: String?
/// The page gzipped once, and the headers announcing it, or `nil` when the bytes did not compress.
private let gzip: (buffer: ByteBuffer, headers: HTTPFields)?
/// The headers applied to every response, precomputed once.
private let headers: HTTPFields
@@ -35,7 +40,7 @@ public struct CachedHTMLResponse: Sendable {
// MARK: Initializers
/// Renders the given document to bytes once.
/// Renders the given document to bytes once, and gzips them 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.
@@ -74,16 +79,19 @@ public struct CachedHTMLResponse: Sendable {
self.eTag = eTag
self.headers = headers
self.status = status
// A page that will not compress is served as rendered.
self.gzip = Self.gzipped(
buffer,
headers: headers
)
}
// 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.
/// A conditional request whose `If-None-Match` names the page's entity tag is answered with a bodyless `304 Not Modified`. Otherwise the
/// page is served: the gzipped copy when the request accepts gzip, and the rendered bytes when it does not.
/// - Parameter request: the request the response answers.
/// - Returns: the response carrying the cached HTML body, or its `304` revalidation.
public func response(
@@ -101,10 +109,24 @@ public struct CachedHTMLResponse: Sendable {
)
}
guard
let gzip,
Self.acceptsGzip(request)
else {
return Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
return Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
headers: gzip.headers,
body: .init { [buffer = gzip.buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
@@ -112,3 +134,122 @@ public struct CachedHTMLResponse: Sendable {
}
}
// MARK: - Helpers
private extension CachedHTMLResponse {
// MARK: Methods
/// Whether the request accepts a gzipped body.
///
/// Only gzip is precomputed: a request asking for another encoding alone falls through to the rendered bytes for ``CompressionMiddleware`` to
/// encode. A `q=0` is a refusal; the wildcard accepts on the client's behalf.
/// - Parameter request: the incoming request.
/// - Returns: `true` when the gzipped copy may be served.
static func acceptsGzip(
_ request: Request
) -> Bool {
var wildcard = false
for value in request.headers[values: .acceptEncoding] {
for entry in value.split(separator: .Separator.comma) {
let parts = entry.split(separator: .Separator.semicolon)
let name = parts.first?
.trimmingCharacters(in: .whitespaces)
.lowercased()
guard
let name,
name == .gzip || name == .xGzip || name == .wildcard
else {
continue
}
let isAccepted = quality(of: parts.dropFirst()) > 0
if name == .wildcard {
wildcard = isAccepted
} else if isAccepted {
return true
} else {
// An explicit `gzip;q=0` refuses it outright, whatever the wildcard says.
return false
}
}
}
return wildcard
}
/// The `q` weight carried by an `Accept-Encoding` entry's parameters; parameters without one are the highest preference.
/// - Parameter parameters: the entry's parameters, the coding name already dropped.
/// - Returns: the entry's weight.
static func quality(
of parameters: some Sequence<Substring>
) -> Double {
for parameter in parameters {
let parameter = parameter
.trimmingCharacters(in: .whitespaces)
.lowercased()
guard parameter.hasPrefix(.qualityPrefix) else {
continue
}
return Double(parameter.dropFirst(String.qualityPrefix.count)) ?? 0
}
return 1
}
/// Gzips the rendered bytes and builds the headers announcing them.
///
/// The copy is complete before the first byte is written, so it carries a `Content-Length` rather than being chunked. Compression that fails or does
/// not pay for itself yields `nil`.
/// - Parameters:
/// - buffer: the rendered bytes.
/// - headers: the headers the response carries before the encoding is announced.
/// - Returns: the compressed bytes and their headers, or `nil` when the page is better served uncompressed.
static func gzipped(
_ buffer: ByteBuffer,
headers: HTTPFields
) -> (buffer: ByteBuffer, headers: HTTPFields)? {
var source = buffer
guard
let compressed = try? source.compress(with: .gzip()),
compressed.readableBytes < buffer.readableBytes
else {
return nil
}
var headers = headers
headers[.contentEncoding] = .gzip
headers[.contentLength] = String(compressed.readableBytes)
return (compressed, headers)
}
}
// MARK: - Constants
private extension Character {
enum Separator {
static let comma: Character = ","
static let semicolon: Character = ";"
}
}
private extension String {
/// The content coding the pages are precompressed with.
static let gzip = "gzip"
/// The prefix of an `Accept-Encoding` entry's weight parameter.
static let qualityPrefix = "q="
/// The content coding some older clients spell `gzip` as.
static let xGzip = "x-gzip"
/// The `Accept-Encoding` entry accepting any coding on the client's behalf.
static let wildcard = "*"
}
@@ -35,7 +35,7 @@ public struct Analytics: Sendable {
/// - 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.
/// - domains: the comma-delimited domains the tracker reports from; 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`.
@@ -61,6 +61,8 @@ public struct Analytics: Sendable {
// MARK: Computed
/// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted.
///
/// An empty ``domains`` is omitted rather than rendered: `data-domains=""` is a filter matching no host.
public var attributes: [Attribute] {
var attributes: [Attribute] = [
.init("data-website-id", value: websiteID)
@@ -2,31 +2,30 @@ import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdTesting
import Localization
import NIOCore
import Testing
@testable import Infrastructure
@Suite(
"LocalizationMiddleware middleware",
.tags(.middleware)
"Negotiate+Request extension",
.tags(.extension)
)
struct LocalizationMiddlewareTests {
struct NegotiateRequestTests {
// MARK: Constants
/// Exercised through a route rather than a hand-built `Request`: the query parameter and path segment it reads are parsed from the URI.
private let app: Application = .init(router: {
let negotiate = Negotiate(bundle: .module)
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
router.get("language") { request, _ in
negotiate(for: request)
}
router.get("language") { _, context in
context.language
}
router.get("de/language") { _, context in
context.language
router.get("de/language") { request, _ in
negotiate(for: request)
}
return router
@@ -18,7 +18,6 @@ struct NotFoundMiddlewareTests {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
NotFoundMiddleware(bundle: .module) {
StubPage(locale: $0)
}
@@ -179,7 +178,6 @@ private extension NotFoundMiddlewareTests {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
NotFoundMiddleware(bundle: .module) {
StubPage(
locale: $0,
@@ -1,17 +1,14 @@
import Hummingbird
import Infrastructure
/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only.
struct StubRequestContext: LocalizedRequestContext {
/// A request context carrying the core storage and nothing else.
struct StubRequestContext: RequestContext {
// 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.
@@ -20,7 +17,6 @@ struct StubRequestContext: LocalizedRequestContext {
source: Source
) {
self.coreContext = .init(source: source)
self.language = ""
}
}