Optimizations for the Website service (#9)
This PR contains the work done to provide optimizations to the current service, such as a health-check endpoint, pre-renders static HTML pages, and hardens the error page's CSP. To provide further details about the work: * Added the `HealthController` controller serving GET `/health` with a static JSON payload. * Added the `CachedHTMLResponse` response, which renders a static HTMLDocument to bytes once and reuses them per request (no Content-Length, so responses stay compressible). * Integrated the response into the `RootController` and the `NotFoundMiddleware` middleware to avoid re-rendering on hot paths. * Added a `RouterMethods.addRoutes(_:)` extension and switched the router in App+build to use it. * Moved the inline style from the `ErrorPage` page into a dedicated style file so the CSP needs no inline-style escape hatch. * Fixed the `IndexPage` page path inconsistencies. * Written the `README` file. Reviewed-on: rock-n-code/loud-amsterdam#9 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
+136
-2
@@ -1,2 +1,136 @@
|
||||
# Website
|
||||
Hummingbird server framework project
|
||||
# Loud Website
|
||||
The **Loud** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets.
|
||||
|
||||
## Overview
|
||||
The service:
|
||||
- Serves the landing page at `GET /` (rendered once with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
|
||||
- Returns a custom HTML 404 page for any request that matches neither a route nor a static file.
|
||||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||||
- Stamps a hardened set of security headers on every response.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Docker (optional) for the containerized run/deploy workflow.
|
||||
|
||||
## Architecture
|
||||
Two SwiftPM targets:
|
||||
| Target | Kind | Path | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. |
|
||||
| `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
|
||||
|
||||
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
||||
```
|
||||
LogRequestsMiddleware
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||
→ NotFoundMiddleware (renders the 404 page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration) from
|
||||
the following sources, **highest precedence first**:
|
||||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||||
2. Process environment variables
|
||||
3. A `.env` file in the working directory (optional)
|
||||
4. Built-in defaults
|
||||
|
||||
### Environment variable naming
|
||||
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
|
||||
`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` →` CACHE_MAX_AGE_TEXT`.
|
||||
> To disable a header or override a value, leave the variable **unset** to fall back to the default. A
|
||||
> variable that is set but **blank** is treated as an explicit empty value, not as "use the default".
|
||||
|
||||
### Static file caching
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for text assets (CSS, JS, plain text); also marked `must-revalidate`. |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
|
||||
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). |
|
||||
|
||||
### Response compression
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. |
|
||||
|
||||
### HTTP server
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `http.host` | `HTTP_HOST` | _none_ | Host the server binds to. Supplied via the `--http-host` CLI flag (the Docker image passes `0.0.0.0`). |
|
||||
| `http.port` | `HTTP_PORT` | _none_ | Port the server listens on. Supplied via the `--http-port` CLI flag (the Docker image passes `8080`). |
|
||||
| `http.serverName` | `HTTP_SERVER_NAME` | `LoudWebsite` | Server name and logger label. |
|
||||
|
||||
### Logging
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
|
||||
|
||||
### Paths
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
||||
|
||||
### Security headers
|
||||
| Config key | Environment variable | Default |
|
||||
| --- | --- | --- |
|
||||
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
|
||||
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
|
||||
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
|
||||
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
|
||||
| `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` |
|
||||
| `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ |
|
||||
|
||||
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: it only takes effect over HTTPS (browsers ignore it on plain HTTP) and is "sticky" in browsers, so it must stay off in local HTTP development. It is enabled for production in `docker-compose.yml`, where it only has an effect once traffic is served over HTTPS behind a TLS-terminating proxy.
|
||||
|
||||
## Running locally
|
||||
Directly with Swift:
|
||||
```sh
|
||||
swift run Website # binds to Hummingbird's default 127.0.0.1:8080
|
||||
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
||||
```
|
||||
|
||||
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets`LOG_LEVEL=debug`:
|
||||
```sh
|
||||
make pkg-build # swift build
|
||||
make img-mount # docker compose up --build --detach
|
||||
make img-unmount # docker compose down + remove the local image
|
||||
```
|
||||
|
||||
`make help` lists every available target.
|
||||
## Testing
|
||||
```sh
|
||||
make pkg-test
|
||||
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
||||
```
|
||||
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The`Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and` WebsiteCoreTests` (the library unit tests).
|
||||
|
||||
## Deployment
|
||||
The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`(`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
|
||||
Build, tag, and push a release to the registry (an explicit version is required):
|
||||
```sh
|
||||
make img-release version=1.2.3
|
||||
```
|
||||
|
||||
Pull and run the prebuilt image in production — the `-f docker-compose.yml` flag is important, as it skips the local-development override:
|
||||
```sh
|
||||
docker compose -f docker-compose.yml pull
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Required variables
|
||||
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
|
||||
| Variable | Used for |
|
||||
| --- | --- |
|
||||
| `HOST_CONTAINER` | Container registry host (e.g. `registry.example.com`). |
|
||||
| `HOST_OWNER` | Registry namespace / owner. |
|
||||
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
|
||||
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. |
|
||||
| `IMAGE_PLATFORM` | Build platform (e.g. `linux/amd64`). |
|
||||
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
|
||||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
|
||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Styles for the not-found (404) error page. */
|
||||
|
||||
* {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color: #888;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: 2em auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 280px) {
|
||||
body, p {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,8 @@ private func logger(
|
||||
/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the
|
||||
/// not-found middleware that serves the error page, and the static file middleware that serves the
|
||||
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then
|
||||
/// adds the `RootController` routes that render the landing page.
|
||||
/// adds the `RootController` routes that render the landing page and the `HealthController` routes
|
||||
/// that serve the health check.
|
||||
///
|
||||
/// The security-headers middleware sits just inside request logging so it covers every response that
|
||||
/// reaches a client — the landing page, the compressed responses, the rendered error page, and the
|
||||
@@ -191,7 +192,10 @@ private func router(
|
||||
)
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<AppRequestContext>().routes)
|
||||
router.addRoutes {
|
||||
RootController<AppRequestContext>().routes
|
||||
HealthController<AppRequestContext>().routes
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
enum StaticFile: CaseIterable, Sendable {
|
||||
/// The `js/app.js` script.
|
||||
case appJS
|
||||
/// The `css/error.css` stylesheet for the not-found page.
|
||||
case errorCSS
|
||||
/// The `favicon.ico` icon.
|
||||
case faviconICO
|
||||
/// The `icon.png` icon.
|
||||
@@ -63,7 +65,8 @@ extension StaticFile {
|
||||
/// The file's extension.
|
||||
var fileExtension: Extension {
|
||||
switch self {
|
||||
case .styleCSS: .css
|
||||
case .errorCSS,
|
||||
.styleCSS: .css
|
||||
case .appJS: .js
|
||||
case .faviconICO: .ico
|
||||
case .iconPNG: .png
|
||||
@@ -77,6 +80,7 @@ extension StaticFile {
|
||||
var fileName: String {
|
||||
switch self {
|
||||
case .appJS: "app"
|
||||
case .errorCSS: "error"
|
||||
case .faviconICO: "favicon"
|
||||
case .iconPNG,
|
||||
.iconSVG: "icon"
|
||||
@@ -102,7 +106,7 @@ extension StaticFile {
|
||||
///
|
||||
/// - Parameter basePath: the directory the static files are served from.
|
||||
/// - Returns: the path to the file, relative to the `basePath` path.
|
||||
public func path(
|
||||
func path(
|
||||
relativeTo basePath: String
|
||||
) -> String {
|
||||
guard !basePath.isEmpty else {
|
||||
@@ -124,7 +128,8 @@ private extension StaticFile {
|
||||
var subdirectory: String? {
|
||||
switch self {
|
||||
case .appJS: "js"
|
||||
case .styleCSS: "css"
|
||||
case .errorCSS,
|
||||
.styleCSS: "css"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ struct ErrorPage: HTMLDocument, Sendable {
|
||||
p { "Sorry, but the page you were trying to view does not exist." }
|
||||
}
|
||||
|
||||
/// The metadata and inline styles placed in the document head.
|
||||
/// The metadata and stylesheet link placed in the document head.
|
||||
var head: some HTML {
|
||||
meta(.charset(.utf8))
|
||||
meta(
|
||||
.name(.viewport),
|
||||
.content("width=device-width, initial-scale=1")
|
||||
)
|
||||
style { Self.styles }
|
||||
link(
|
||||
.rel(.stylesheet),
|
||||
.href("/css/error.css")
|
||||
)
|
||||
}
|
||||
|
||||
/// The document language.
|
||||
@@ -28,52 +31,3 @@ struct ErrorPage: HTMLDocument, Sendable {
|
||||
var title: String { "Page Not Found" }
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension ErrorPage {
|
||||
/// The inline CSS applied to the page.
|
||||
static let styles = """
|
||||
* {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color: #888;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: 2em auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 280px) {
|
||||
body, p {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em;
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ struct IndexPage: HTMLDocument, Sendable {
|
||||
/// The page's content.
|
||||
var body: some HTML {
|
||||
p { "Hello world! This is HTML5 Boilerplate." }
|
||||
script(.src("js/app.js")) {}
|
||||
script(.src("/js/app.js")) {}
|
||||
}
|
||||
|
||||
/// The metadata, stylesheet, icon, and manifest links placed in the document head.
|
||||
@@ -20,7 +20,7 @@ struct IndexPage: HTMLDocument, Sendable {
|
||||
)
|
||||
link(
|
||||
.rel(.stylesheet),
|
||||
.href("css/style.css")
|
||||
.href("/css/style.css")
|
||||
)
|
||||
link(
|
||||
.rel(.icon),
|
||||
@@ -40,11 +40,11 @@ struct IndexPage: HTMLDocument, Sendable {
|
||||
)
|
||||
link(
|
||||
.rel("apple-touch-icon"),
|
||||
.href("icon.png")
|
||||
.href("/icon.png")
|
||||
)
|
||||
link(
|
||||
.rel("manifest"),
|
||||
.href("site.webmanifest")
|
||||
.href("/site.webmanifest")
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
@@ -58,6 +58,8 @@ struct IndexPage: HTMLDocument, Sendable {
|
||||
}
|
||||
|
||||
/// The document title.
|
||||
var title: String { "" }
|
||||
var title: String {
|
||||
"Index page"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Elementary
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
|
||||
/// A pre-rendered HTTP response for a fully static HTML page.
|
||||
///
|
||||
/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those
|
||||
/// bytes instead of re-rendering. This suits pages whose markup never changes between requests — the
|
||||
/// landing page and the not-found page — avoiding a per-request Elementary render on hot paths.
|
||||
///
|
||||
/// 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.
|
||||
struct CachedHTMLResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The status applied to every response.
|
||||
private let status: HTTPResponse.Status
|
||||
/// The page rendered to bytes once.
|
||||
private let buffer: ByteBuffer
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Renders the given document to bytes once.
|
||||
/// - Parameters:
|
||||
/// - status: the status applied to every response. Defaults to `.ok`.
|
||||
/// - document: the static HTML document to render and cache.
|
||||
init(
|
||||
status: HTTPResponse.Status = .ok,
|
||||
_ document: some HTMLDocument
|
||||
) {
|
||||
self.status = status
|
||||
self.buffer = ByteBuffer(string: document.render())
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds a response from the cached, pre-rendered bytes.
|
||||
///
|
||||
/// Mirrors the `text/html; charset=utf-8` content type `HTMLResponse` produces, and leaves the
|
||||
/// `Content-Length` unset so small pages remain eligible for compression.
|
||||
/// - Returns: the response carrying the cached HTML body.
|
||||
func response() -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: [.contentType: "text/html; charset=utf-8"],
|
||||
body: .init { [buffer] writer in
|
||||
try await writer.write(buffer)
|
||||
try await writer.finish(nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
|
||||
/// Serves the website's health-check route.
|
||||
///
|
||||
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
|
||||
/// (or a sub-group) by the application that composes it:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addRoutes(HealthController<AppRequestContext>().routes)
|
||||
/// ```
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the
|
||||
/// context of the router the routes are added to.
|
||||
public struct HealthController<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The JSON payload returned for every health check.
|
||||
private let payload: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a health controller.
|
||||
public init() {
|
||||
self.payload = #"{"status":"ok"}"#
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The routes served by the controller.
|
||||
///
|
||||
/// Serves a `GET` request for the health path (`/health`) with a static JSON status payload.
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Health.check,
|
||||
use: check
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HealthController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a request for the health check.
|
||||
///
|
||||
/// Returns a constant JSON body built directly per request — the payload is a tiny literal with no
|
||||
/// rendering step, so there is nothing to pre-render or cache.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: a `200 OK` response carrying the static JSON status payload.
|
||||
@Sendable
|
||||
func check(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) -> Response {
|
||||
Response(
|
||||
status: .ok,
|
||||
headers: [.contentType: "application/json"],
|
||||
body: .init(byteBuffer: .init(string: payload))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``HealthController`` route paths.
|
||||
enum Health {
|
||||
/// The path of the health-check endpoint.
|
||||
static let check: RouterPath = "/health"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
|
||||
/// Serves the website's root routes.
|
||||
///
|
||||
@@ -12,14 +11,21 @@ import HummingbirdElementary
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the
|
||||
/// context of the router the routes are added to.
|
||||
public struct RootController<Context: RequestContext> : Sendable{
|
||||
public struct RootController<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The landing page, rendered once at initialization and reused for every request.
|
||||
private let cache: CachedHTMLResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a root controller.
|
||||
public init() {}
|
||||
public init() {
|
||||
self.cache = .init(IndexPage())
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
// MARK: Computed
|
||||
|
||||
/// The routes served by the controller.
|
||||
///
|
||||
@@ -40,29 +46,27 @@ public struct RootController<Context: RequestContext> : Sendable{
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RootController {
|
||||
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a request for the landing page.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: an HTML response that renders the ``IndexPage``.
|
||||
/// - Returns: the cached ``IndexPage`` response.
|
||||
@Sendable
|
||||
func index(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) -> HTMLResponse {
|
||||
.init {
|
||||
IndexPage()
|
||||
}
|
||||
) -> Response {
|
||||
cache.response()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
extension RouterPath {
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``RootController`` route paths.
|
||||
enum Root {
|
||||
/// The path of the landing page.
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import Hummingbird
|
||||
|
||||
/// A result builder that collects ``RouteCollection`` values into a stack.
|
||||
///
|
||||
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route
|
||||
/// collections be listed declaratively rather than added one statement at a time.
|
||||
@resultBuilder
|
||||
public enum RouteCollectionBuilder<Context: RequestContext> {
|
||||
|
||||
public static func buildExpression(
|
||||
_ collection: RouteCollection<Context>
|
||||
) -> [RouteCollection<Context>] {
|
||||
[collection]
|
||||
}
|
||||
|
||||
public static func buildBlock(
|
||||
_ collections: [RouteCollection<Context>]...
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections.flatMap { $0 }
|
||||
}
|
||||
|
||||
public static func buildOptional(
|
||||
_ collections: [RouteCollection<Context>]?
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections ?? []
|
||||
}
|
||||
|
||||
public static func buildEither(
|
||||
first collections: [RouteCollection<Context>]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections
|
||||
}
|
||||
|
||||
public static func buildEither(
|
||||
second collections: [RouteCollection<Context>]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections
|
||||
}
|
||||
|
||||
public static func buildArray(
|
||||
_ collections: [[RouteCollection<Context>]]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections.flatMap { $0 }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
public extension RouterMethods {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder.
|
||||
///
|
||||
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addRoutes {
|
||||
/// RootController<AppRequestContext>().routes
|
||||
/// HealthController<AppRequestContext>().routes
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Each collection is added at the router's root, exactly as a sequence of
|
||||
/// `addRoutes(_:)` calls would.
|
||||
/// - Parameter build: the route-collection stack result builder.
|
||||
/// - Returns: the router, so calls can be chained.
|
||||
@discardableResult
|
||||
func addRoutes(
|
||||
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
|
||||
) -> Self {
|
||||
for collection in build() {
|
||||
addRoutes(collection)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,10 +11,11 @@ extension String {
|
||||
public enum Security {
|
||||
/// The default `Content-Security-Policy`.
|
||||
///
|
||||
/// Restricts every resource to the site's own origin. `style-src` additionally allows
|
||||
/// `'unsafe-inline'` because ``ErrorPage`` ships an inline `<style>` block; remove it once
|
||||
/// the error page's styles move to an external stylesheet.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
/// 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'`). Both pages link external stylesheets, so no inline-style
|
||||
/// exception is required.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||
public static let contentTypeOptions = "nosniff"
|
||||
/// The default `X-Frame-Options` (forbids framing the page).
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import Elementary
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
|
||||
/// Serves a custom error page for requests that match neither a route nor a static file.
|
||||
///
|
||||
@@ -9,10 +7,20 @@ import HummingbirdElementary
|
||||
/// ``ErrorPage`` and a `404 Not Found` status.
|
||||
public struct NotFoundMiddleware<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The error page, rendered once at initialization and reused for every not-found response.
|
||||
private let cache: CachedHTMLResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware.
|
||||
public init() {}
|
||||
public init() {
|
||||
self.cache = .init(
|
||||
status: .notFound,
|
||||
ErrorPage()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -39,7 +47,8 @@ extension NotFoundMiddleware: RouterMiddleware {
|
||||
) async throws -> Response {
|
||||
do {
|
||||
return try await next(request, context)
|
||||
} catch let error {
|
||||
}
|
||||
catch let error {
|
||||
guard
|
||||
let responseError = error as? any HTTPResponseError,
|
||||
responseError.status == .notFound
|
||||
@@ -47,15 +56,7 @@ extension NotFoundMiddleware: RouterMiddleware {
|
||||
throw error
|
||||
}
|
||||
|
||||
return HTMLResponse(
|
||||
status: .notFound
|
||||
) {
|
||||
ErrorPage()
|
||||
}
|
||||
.response(
|
||||
from: request,
|
||||
context: context
|
||||
)
|
||||
return cache.response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ struct StaticFileTests {
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 7)
|
||||
#expect(File.allCases.count == 8)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -95,6 +95,7 @@ private extension StaticFileTests {
|
||||
|
||||
static let contentTypes: [String] = [
|
||||
"text/javascript",
|
||||
"text/css",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
@@ -104,6 +105,7 @@ private extension StaticFileTests {
|
||||
]
|
||||
static let fileExtensions: [FileExtension] = [
|
||||
.js,
|
||||
.css,
|
||||
.ico,
|
||||
.png,
|
||||
.svg,
|
||||
@@ -113,6 +115,7 @@ private extension StaticFileTests {
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"app",
|
||||
"error",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon",
|
||||
@@ -122,6 +125,7 @@ private extension StaticFileTests {
|
||||
]
|
||||
static let relativePaths: [String] = [
|
||||
"js/app.js",
|
||||
"css/error.css",
|
||||
"favicon.ico",
|
||||
"icon.png",
|
||||
"icon.svg",
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ErrorPageTests {
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains("Page Not Found"))
|
||||
#expect(html.contains("does not exist"))
|
||||
#expect(html.contains("/css/error.css"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,9 +13,13 @@ struct IndexPageTests {
|
||||
let html = IndexPage().render()
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains("/css/style.css"))
|
||||
#expect(html.contains("/favicon.ico"))
|
||||
#expect(html.contains("/icon.svg"))
|
||||
#expect(html.contains("/icon.png"))
|
||||
#expect(html.contains("/site.webmanifest"))
|
||||
#expect(html.contains("Hello world!"))
|
||||
#expect(html.contains("css/style.css"))
|
||||
#expect(html.contains("js/app.js"))
|
||||
#expect(html.contains("/js/app.js"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteCore
|
||||
|
||||
@Suite("HealthController controller")
|
||||
struct HealthControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router()
|
||||
|
||||
router.addRoutes(HealthController<BasicRequestContext>().routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `serves the status payload at the health path`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user