Renamed the Web package as Infrastructure (#25)
This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: rock-n-code/loud-amsterdam#25 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:
@@ -2,6 +2,7 @@ import Configuration
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@@ -12,11 +13,11 @@ import Testing
|
||||
struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let textExtensions: [StaticFile.Extension] = [
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js,
|
||||
.txt
|
||||
.js
|
||||
]
|
||||
|
||||
// Absolute path to the package's "Resources/Static" folder, derived from this
|
||||
@@ -48,6 +49,22 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to answer a head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `health check to be served at the health path`() async throws {
|
||||
try await app(
|
||||
@@ -106,7 +123,9 @@ struct AppTests {
|
||||
#expect(cacheControl.contains("public") == true)
|
||||
#expect(cacheControl.contains("max-age=") == true)
|
||||
|
||||
if textExtensions.contains(fileExtension) {
|
||||
if immutableExtensions.contains(fileExtension) {
|
||||
#expect(cacheControl.contains("immutable") == true)
|
||||
} else if fileExtension == .txt {
|
||||
#expect(cacheControl.contains("must-revalidate") == true)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +133,81 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `versioned asset URL to be served`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/css/shared.css?v=0123456789abcdef",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/css")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/shared.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate with an entity tag`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||
|
||||
return try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
#expect(response.headers[.eTag] == eTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `responses to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `response to be compressed when the client supports it`() async throws {
|
||||
try await app(
|
||||
@@ -163,6 +257,81 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/error.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served without revalidation headers`() async throws {
|
||||
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||
// invite revalidation with an entity tag or a cache policy.
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.eTag] == nil)
|
||||
#expect(response.headers[.cacheControl] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate a conditional head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the landing page`() async throws {
|
||||
try await app(
|
||||
|
||||
Reference in New Issue
Block a user