From e3e278dacb4efa526b6320b7e7e9dc7ebc279207 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 26 Jun 2026 15:44:05 +0200 Subject: [PATCH 001/117] Initial Commit --- Loud.xcodeproj/project.pbxproj | 72 +++++++++++++++++++ .../contents.xcworkspacedata | 7 ++ 2 files changed, 79 insertions(+) create mode 100644 Loud.xcodeproj/project.pbxproj create mode 100644 Loud.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Loud.xcodeproj/project.pbxproj b/Loud.xcodeproj/project.pbxproj new file mode 100644 index 0000000..727bc00 --- /dev/null +++ b/Loud.xcodeproj/project.pbxproj @@ -0,0 +1,72 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 110; + objects = { + +/* Begin PBXGroup section */ + 02642FD02FEEB9A5007FA466 = { + isa = PBXGroup; + children = ( + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXProject section */ + 02642FD12FEEB9A5007FA466 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + }; + buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Loud" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 02642FD02FEEB9A5007FA466; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ); + }; +/* End PBXProject section */ + +/* Begin XCBuildConfiguration section */ + 02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Loud" */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = 7FMNM89WKG; + }; + name = Debug; + }; + 02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Loud" */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = 7FMNM89WKG; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Loud" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Loud" */, + 02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Loud" */, + ); + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 02642FD12FEEB9A5007FA466 /* Project object */; + validationLevel = 1; +} diff --git a/Loud.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Loud.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Loud.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + -- 2.54.0 From a15271772e8c5379c4b68587814ce97fcfd216f7 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 26 Jun 2026 21:08:28 +0000 Subject: [PATCH 002/117] Xcode project setup (#1) This PR contains the work done to setup the *Xcode* project, with its `.gitignore` and `.swift-format` files. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/1 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .gitignore | 54 ++++++++++++++++++++++++++++ .swift-format | 65 ++++++++++++++++++++++++++++++++++ Loud.xcodeproj/project.pbxproj | 24 ++++++++++++- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .swift-format diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a05440 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* + +# Xcode +## User settings +xcuserdata/ + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Other +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +## Build output and resolved dependency checkouts +.build/ +## SwiftPM's local registry config and per-user Xcode data (schemes live in +## xcuserdata, already ignored above). Shared .swiftpm metadata is committed. +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata + +# NOTE: `Packages/` is intentionally NOT ignored — it holds this repo's local +# Swift packages (source), not checked-out dependencies. +# NOTE: `Package.resolved` is intentionally NOT ignored — committing it locks +# dependency versions across the team and CI. + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output \ No newline at end of file diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..f015b36 --- /dev/null +++ b/.swift-format @@ -0,0 +1,65 @@ +{ + "version": 1, + "lineLength": 200, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "lineBreakBetweenDeclarationAttributes": true, + "prioritizeKeepingFunctionOutputTogether": false, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "spacesAroundRangeFormationOperators": true, + "multiElementCollectionTrailingCommas": true, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": true, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } +} diff --git a/Loud.xcodeproj/project.pbxproj b/Loud.xcodeproj/project.pbxproj index 727bc00..9b48f17 100644 --- a/Loud.xcodeproj/project.pbxproj +++ b/Loud.xcodeproj/project.pbxproj @@ -6,10 +6,31 @@ objectVersion = 110; objects = { +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 02B32DD62FEEC5A700DA6788 /* Apps */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Apps; + sourceTree = ""; + }; + 02B32DD72FEEC5AE00DA6788 /* Services */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Services; + sourceTree = ""; + }; + 02B32DD82FEEC5B400DA6788 /* Packages */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Packages; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXGroup section */ 02642FD02FEEB9A5007FA466 = { isa = PBXGroup; children = ( + 02B32DD82FEEC5B400DA6788 /* Packages */, + 02B32DD72FEEC5AE00DA6788 /* Services */, + 02B32DD62FEEC5A700DA6788 /* Apps */, ); sourceTree = ""; }; @@ -21,6 +42,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastUpgradeCheck = 2700; + ORGANIZATIONNAME = "Röck+Cöde VoF"; }; buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Loud" */; developmentRegion = en; @@ -31,7 +53,7 @@ ); mainGroup = 02642FD02FEEB9A5007FA466; minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; + preferredProjectObjectVersion = 110; projectDirPath = ""; projectRoot = ""; targets = ( -- 2.54.0 From 08b210e7c6404d296a4473e4f45d4d2d4b171d92 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 27 Jun 2026 02:26:33 +0000 Subject: [PATCH 003/117] Website service target setup (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to add and setup the *Website* service target, a **Hummingbird** server app, into the Xcode project as a SwiftPM package with full support for containerization and driven by a `Makefile` file. To provide further details about the work done: * Swift package — SwiftPM manifest with a Website executable, related library, and test targets; depends on **hummingbird** and **swift-configuration**. * Containerization — Multi-stage `Dockerfile` producing a static-linked release build with jemalloc, running as a non-root user on port 8080. Production and local-dev `docker-compose` files included. * Configuration — `.env.local` template (with `.env` git-ignored) and `.dockerignore`/`.gitignore` entries. * Makefile — Self-documenting operational commands: * pkg — SwiftPM: _build, release, test, clean, reset, deps, outdated, update_ * img — Docker lifecycle: _build, mount, unmount, release_ Notes * New service only — no changes to existing code; nothing else in the repo is affected. * App logic is currently scaffolding; this PR establishes the service structure, build, and deployment tooling. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/2 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .gitignore | 4 + Services/Website/.dockerignore | 6 + Services/Website/.env.local | 42 +++++++ Services/Website/Dockerfile | 87 ++++++++++++++ Services/Website/Makefile | 107 ++++++++++++++++++ Services/Website/Package.swift | 74 ++++++++++++ Services/Website/README.md | 2 + Services/Website/Sources/App/App+build.swift | 62 ++++++++++ Services/Website/Sources/App/App.swift | 28 +++++ .../Website/Sources/Library/Library.swift | 2 + Services/Website/Tests/App/AppTests.swift | 34 ++++++ .../Website/Tests/Library/LibraryTest.swift | 1 + Services/Website/docker-compose.override.yml | 18 +++ Services/Website/docker-compose.yml | 22 ++++ 14 files changed, 489 insertions(+) create mode 100644 Services/Website/.dockerignore create mode 100644 Services/Website/.env.local create mode 100644 Services/Website/Dockerfile create mode 100644 Services/Website/Makefile create mode 100644 Services/Website/Package.swift create mode 100644 Services/Website/README.md create mode 100644 Services/Website/Sources/App/App+build.swift create mode 100644 Services/Website/Sources/App/App.swift create mode 100644 Services/Website/Sources/Library/Library.swift create mode 100644 Services/Website/Tests/App/AppTests.swift create mode 100644 Services/Website/Tests/Library/LibraryTest.swift create mode 100644 Services/Website/docker-compose.override.yml create mode 100644 Services/Website/docker-compose.yml diff --git a/.gitignore b/.gitignore index 0a05440..e33ea89 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ playground.xcworkspace # NOTE: `Package.resolved` is intentionally NOT ignored — committing it locks # dependency versions across the team and CI. +# Docker / environment +.env +!.env.local + # Fastlane fastlane/report.xml fastlane/Preview.html diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore new file mode 100644 index 0000000..5842fb7 --- /dev/null +++ b/Services/Website/.dockerignore @@ -0,0 +1,6 @@ +.build +.swiftpm +.env.local +docker-compose.* +Makefile +README.md \ No newline at end of file diff --git a/Services/Website/.env.local b/Services/Website/.env.local new file mode 100644 index 0000000..a3d7faa --- /dev/null +++ b/Services/Website/.env.local @@ -0,0 +1,42 @@ +# Copy this file to `.env` and adjust values as needed. +# cp .env.example .env +# +# Compose reads `.env` automatically to fill the ${VAR} placeholders in +# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via +# swift-configuration (allowMissing: true), so any extra app config keys placed +# here are picked up by the running service too. + +# --- Image / deployment ------------------------------------------------------- + +# Host name of the container running the Website service. +HOST_CONTAINER=containers.rock-n-code.com + +# Owner of the container running the Website service. +HOST_OWNER=loud + +# Password for authenticating to the container registry. +HOST_PASSWORD= + +# Host port mapped to the container's port 8080. +HOST_PORT=8080 + +# User name for authenticating to the container registry. +HOST_USER= + +# Name of the Docker image to pull/run. +IMAGE_NAME=website + +# Platform of the Docker image to pull/run. +IMAGE_PLATFORM=linux/arm64 + +# Tag of the image to pull/run. +# Use a semver in production; avoid `latest` so rollbacks are unambiguous. +IMAGE_TAG=latest + +# --- Application config ------------------------------------------------------- + +# Server name reported by the HTTP service. +HTTP_SERVER_NAME=LoudWebsite + +# Log verbosity: trace | debug | info | notice | warning | error | critical +LOG_LEVEL=info diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile new file mode 100644 index 0000000..a08d2fb --- /dev/null +++ b/Services/Website/Dockerfile @@ -0,0 +1,87 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.3-noble AS build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev \ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +COPY ./Package.* ./ +RUN swift package resolve + +# Copy entire repo into container +COPY . . + +# Build the application, with optimizations, with static linking, and using jemalloc +RUN swift build -c release \ + --product "Website" \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Website" ./ + +# Copy static swift backtracer binary to staging area +RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ + +# Copy resources bundled by SPM to staging area +RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resouces from the public directory and views directory if the directories exist +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true + +# ================================ +# Run image +# ================================ +FROM ubuntu:noble + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a hummingbird user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=hummingbird:hummingbird /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static + +# Ensure all further commands run as the hummingbird user +USER hummingbird:hummingbird + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./Website"] +CMD ["--http-host", "0.0.0.0", "--http-port", "8080"] diff --git a/Services/Website/Makefile b/Services/Website/Makefile new file mode 100644 index 0000000..98cb6b7 --- /dev/null +++ b/Services/Website/Makefile @@ -0,0 +1,107 @@ +# Website service — operational commands +# --- Configuration ------------------------------------------------------------ + +# Load configuration from .env (the same file Compose reads). +ENV_FILE := $(if $(wildcard .env),.env,.env.local) +include $(ENV_FILE) +export + +# Variables +IMAGE_URL := $(HOST_CONTAINER)/$(HOST_OWNER)/$(IMAGE_NAME) + +# Arguments +override version ?= $(IMAGE_TAG) + +# Show help if no target is specified. +.DEFAULT_GOAL := help + +# --- Swift package ------------------------------------------------------------ + +.PHONY: pkg-build +pkg-build: ## Build the Swift package + @swift build + +.PHONY: pkg-release +pkg-release: ## Release the Swift package + @swift build -c release + +.PHONY: pkg-test +pkg-test: ## Run the Swift package tests + @swift test \ + --disable-xctest \ + --enable-code-coverage \ + --enable-swift-testing \ + --parallel + +.PHONY: pkg-clean +pkg-clean: ## Remove the Swift build artifacts + @swift package clean + +.PHONY: pkg-reset +pkg-reset: ## Resets the complete SPM cache/build folder + @swift package reset + +.PHONY: pkg-deps +pkg-deps: ## Lists the SPM package dependencies + @swift package show-dependencies + +.PHONY: pkg-outdated +pkg-outdated: ## Lists the SPM package dependencies that can be updated + @swift package update --dry-run + +.PHONY: pkg-update +pkg-update: ## Updates the SPM package dependencies + @swift package update + +# --- Local development -------------------------------------------------------- + +.PHONY: img-build +img-build: ## Build the local dev image + @docker compose build + +.PHONY: img-mount +img-mount: ## Mount the service locally (build if needed) + @docker compose up --build --detach + +.PHONY: img-unmount +img-unmount: ## Unmount and remove the local service + @docker compose down + @$(MAKE) img-remove + +# --- Registry deployment ------------------------------------------------------ + +.PHONY: img-release +img-release: ## Build the production (amd64) image, tag with version + latest, push both + @if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \ + echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \ + exit 1; \ + fi + @docker build \ + --platform $(IMAGE_PLATFORM) \ + --tag $(IMAGE_URL):$(version) \ + --tag $(IMAGE_URL):latest \ + . + @echo "${HOST_PASSWORD}" \ + | docker login $(HOST_CONTAINER) \ + --username $(HOST_USER) \ + --password-stdin + @docker push $(IMAGE_URL):$(version) + @docker push $(IMAGE_URL):latest + @docker logout $(HOST_CONTAINER) + @$(MAKE) img-remove + +.PHONY: img-remove +img-remove: # Removes the generated Docker images + @images="$$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep '$(IMAGE_NAME)' | awk '{print $$1}')"; \ + if [ -n "$$images" ]; then \ + docker image rm --force $$images; \ + else \ + echo "No '$(IMAGE_NAME)' images to remove."; \ + fi + +# --- Meta --------------------------------------------------------------------- + +.PHONY: help +help: ## Show available commands + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift new file mode 100644 index 0000000..a6d6bf0 --- /dev/null +++ b/Services/Website/Package.swift @@ -0,0 +1,74 @@ +// swift-tools-version:6.3 + +import PackageDescription + +let package = Package( + name: "Website", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + ], + products: [ + .executable( + name: "Website", + targets: [ + "Website", + "WebsiteCore", + ] + ) + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird.git", + from: "2.25.0" + ), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: [ + .defaults, + "CommandLineArguments", + ] + ), + ], + targets: [ + .executableTarget( + name: "Website", + dependencies: [ + .product( + name: "Configuration", + package: "swift-configuration" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + ], + path: "Sources/App" + ), + .target( + name: "WebsiteCore", + dependencies: [], + path: "Sources/Library" + ), + .testTarget( + name: "WebsiteTests", + dependencies: [ + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + .byName(name: "Website"), + ], + path: "Tests/App" + ), + .testTarget( + name: "WebsiteCoreTests", + dependencies: [ + .byName(name: "WebsiteCore"), + ], + path: "Tests/Library" + ), + ] +) diff --git a/Services/Website/README.md b/Services/Website/README.md new file mode 100644 index 0000000..eb5764d --- /dev/null +++ b/Services/Website/README.md @@ -0,0 +1,2 @@ +# Website +Hummingbird server framework project \ No newline at end of file diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift new file mode 100644 index 0000000..d5df224 --- /dev/null +++ b/Services/Website/Sources/App/App+build.swift @@ -0,0 +1,62 @@ +import Configuration +import Hummingbird +import Logging + +/// Build application +/// - Parameter reader: configuration reader +func application( + reader: ConfigReader +) async throws -> some ApplicationProtocol { + let logLevel = reader.string( + forKey: "log.level", + as: Logger.Level.self, + default: .info + ) + let serverName = reader.string( + forKey: "http.serverName", + default: "LoudWebsite" + ) + + return Application( + router: try router(), + configuration: ApplicationConfiguration( + reader: reader.scoped(to: "http") + ), + logger: logger( + serverName: serverName, + logLevel: logLevel + ) + ) +} + +// MARK: - Helpers + +// Request context used by application +private typealias AppRequestContext = BasicRequestContext + +/// Build logger +private func logger( + serverName: String, + logLevel: Logger.Level +) -> Logger { + var logger = Logger(label: serverName) + + logger.logLevel = logLevel + + return logger +} + +/// Build router +private func router() throws -> Router { + let router = Router(context: AppRequestContext.self) + + router.addMiddleware { + LogRequestsMiddleware(.info) + } + + router.get("/") { _, _ in + return "Hello!" + } + + return router +} diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift new file mode 100644 index 0000000..c46a698 --- /dev/null +++ b/Services/Website/Sources/App/App.swift @@ -0,0 +1,28 @@ +import Configuration +import Hummingbird +import Logging + +@main +struct App { + static func main() async throws { + let reader = try await ConfigReader( + providers: [ + CommandLineArgumentsProvider(), + EnvironmentVariablesProvider(), + EnvironmentVariablesProvider( + environmentFilePath: ".env", + allowMissing: true + ), + InMemoryProvider(values: [ + "http.serverName": "LoudWebsite" + ]), + ] + ) + + let app = try await application( + reader: reader + ) + + try await app.runService() + } +} diff --git a/Services/Website/Sources/Library/Library.swift b/Services/Website/Sources/Library/Library.swift new file mode 100644 index 0000000..139597f --- /dev/null +++ b/Services/Website/Sources/Library/Library.swift @@ -0,0 +1,2 @@ + + diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift new file mode 100644 index 0000000..379de9f --- /dev/null +++ b/Services/Website/Tests/App/AppTests.swift @@ -0,0 +1,34 @@ +import Configuration +import Hummingbird +import HummingbirdTesting +import Logging +import Testing + +@testable import Website + +private let reader = ConfigReader(providers: [ + InMemoryProvider(values: [ + "http.host": "127.0.0.1", + "http.port": "0", + "log.level": "trace", + ]) +]) + +@Suite +struct AppTests { + @Test + func hello() async throws { + let app = try await application( + reader: reader + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } +} diff --git a/Services/Website/Tests/Library/LibraryTest.swift b/Services/Website/Tests/Library/LibraryTest.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Services/Website/Tests/Library/LibraryTest.swift @@ -0,0 +1 @@ + diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml new file mode 100644 index 0000000..22467b1 --- /dev/null +++ b/Services/Website/docker-compose.override.yml @@ -0,0 +1,18 @@ +# Local development overrides. +# Compose merges this file on top of docker-compose.yml automatically, so a +# plain `docker compose up` builds from source instead of pulling a registry image: +# +# docker compose up --build # build locally and run +# docker compose up -d # reuse the last local build +# +# It reuses the `image:` name from the base file, so the local build is tagged +# the same way the production image would be. +services: + website: + image: ${IMAGE_NAME}:${IMAGE_TAG:-latest} + platform: linux/arm64 + build: + context: . + dockerfile: Dockerfile + environment: + LOG_LEVEL: debug diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml new file mode 100644 index 0000000..8a29503 --- /dev/null +++ b/Services/Website/docker-compose.yml @@ -0,0 +1,22 @@ +name: loud-platform + +# Production base configuration. +# Deploys a pre-built image pulled from a registry — no build step. +# +# docker compose -f docker-compose.yml pull +# docker compose -f docker-compose.yml up -d +# +# The `-f docker-compose.yml` flag is important in production: it skips the +# docker-compose.override.yml file, which Compose would otherwise merge in +# automatically for local development. +services: + website: + image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest} + platform: linux/amd64 + container_name: ${HOST_OWNER}-${IMAGE_NAME} + restart: unless-stopped + ports: + - "${HOST_PORT:-8080}:8080" + environment: + LOG_LEVEL: ${LOG_LEVEL:-info} + HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite} -- 2.54.0 From 7542bc595fd79a6a972898cd21be1455cbf76955 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 27 Jun 2026 10:59:32 +0000 Subject: [PATCH 004/117] Static file serving support for the Website service (#3) This PR contains the work done to support the static files serving for the **Website** service, and also included the essential boilerplate assets from the **HTML5 boilerplate** project. To provide further details about the work done: * Serving: Added the `FileMiddleware` middlewqare to the router; `path`, `server name`, and `log level` now read from config with defaults. * Library: Added the `StaticFile` enumeration with a `contentType` property, plus typed config-key/value constants and the `Configuration` dependency on WebsiteCore. * Assets: Added the **HTML5 boilerplate** (HTML, CSS, JS, icons, manifest, robots) to the *Resources/Static* folder. * Docker: Stage the Resources directory as read-only. * Tests: Added a test plan, and a shared Xcode scheme. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/3 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/.dockerignore | 1 + .../xcshareddata/xcschemes/Website.xcscheme | 92 +++++++ Services/Website/Dockerfile | 4 +- Services/Website/Package.swift | 10 +- Services/Website/Resources/Static/404.html | 62 +++++ .../Website/Resources/Static/css/style.css | 246 ++++++++++++++++++ Services/Website/Resources/Static/favicon.ico | Bin 0 -> 766 bytes Services/Website/Resources/Static/icon.png | Bin 0 -> 4029 bytes Services/Website/Resources/Static/icon.svg | 1 + Services/Website/Resources/Static/index.html | 33 +++ Services/Website/Resources/Static/js/app.js | 0 Services/Website/Resources/Static/robots.txt | 5 + .../Website/Resources/Static/site.webmanifest | 12 + Services/Website/Sources/App/App+build.swift | 55 +++- Services/Website/Sources/App/App.swift | 2 +- .../Internal/Enumerations/StaticFile.swift | 166 ++++++++++++ .../Website/Sources/Library/Library.swift | 2 - .../AbsoluteConfigKey+Constants.swift | 23 ++ .../Extensions/ConfigKey+Constants.swift | 23 ++ .../Extensions/ConfigValue+Constants.swift | 18 ++ .../Public/Extensions/String+Constants.swift | 12 + Services/Website/Tests/App/AppTests.swift | 63 ++++- .../Enumerations/StaticFileTests.swift | 141 ++++++++++ .../Website/Tests/Library/LibraryTest.swift | 1 - Services/Website/Website.xctestplan | 37 +++ 25 files changed, 973 insertions(+), 36 deletions(-) create mode 100644 Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme create mode 100644 Services/Website/Resources/Static/404.html create mode 100644 Services/Website/Resources/Static/css/style.css create mode 100644 Services/Website/Resources/Static/favicon.ico create mode 100644 Services/Website/Resources/Static/icon.png create mode 100644 Services/Website/Resources/Static/icon.svg create mode 100644 Services/Website/Resources/Static/index.html create mode 100644 Services/Website/Resources/Static/js/app.js create mode 100644 Services/Website/Resources/Static/robots.txt create mode 100644 Services/Website/Resources/Static/site.webmanifest create mode 100644 Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift delete mode 100644 Services/Website/Sources/Library/Library.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/String+Constants.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift delete mode 100644 Services/Website/Tests/Library/LibraryTest.swift create mode 100644 Services/Website/Website.xctestplan diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore index 5842fb7..1f3233b 100644 --- a/Services/Website/.dockerignore +++ b/Services/Website/.dockerignore @@ -1,5 +1,6 @@ .build .swiftpm +.DS_Store .env.local docker-compose.* Makefile diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme new file mode 100644 index 0000000..01a4a46 --- /dev/null +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index a08d2fb..e3f71bf 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -41,9 +41,9 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; -# Copy any resouces from the public directory and views directory if the directories exist +# Copy the static files directory (served by FileMiddleware) if it exists # Ensure that by default, neither the directory nor any of its contents are writable. -RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true # ================================ # Run image diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index a6d6bf0..e44f6bb 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -36,6 +36,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "WebsiteCore"), .product( name: "Configuration", package: "swift-configuration" @@ -49,17 +50,22 @@ let package = Package( ), .target( name: "WebsiteCore", - dependencies: [], + dependencies: [ + .product( + name: "Configuration", + package: "swift-configuration" + ), + ], path: "Sources/Library" ), .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Website"), .product( name: "HummingbirdTesting", package: "hummingbird" ), - .byName(name: "Website"), ], path: "Tests/App" ), diff --git a/Services/Website/Resources/Static/404.html b/Services/Website/Resources/Static/404.html new file mode 100644 index 0000000..260cc4c --- /dev/null +++ b/Services/Website/Resources/Static/404.html @@ -0,0 +1,62 @@ + + + + + + Page Not Found + + + + + +

Page Not Found

+

Sorry, but the page you were trying to view does not exist.

+ + + + diff --git a/Services/Website/Resources/Static/css/style.css b/Services/Website/Resources/Static/css/style.css new file mode 100644 index 0000000..ba80dc8 --- /dev/null +++ b/Services/Website/Resources/Static/css/style.css @@ -0,0 +1,246 @@ +/*! HTML5 Boilerplate v9.0.1 | MIT License | https://html5boilerplate.com/ */ + +/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */ +/* + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + */ + +/* ========================================================================== + Base styles: opinionated defaults + ========================================================================== */ + +html { + color: #222; + font-size: 1em; + line-height: 1.4; +} + +/* + * Remove text-shadow in selection highlight: + * https://twitter.com/miketaylr/status/12228805301 + * + * Customize the background color to match your design. + */ + +::-moz-selection { + background: #b3d4fc; + text-shadow: none; +} + +::selection { + background: #b3d4fc; + text-shadow: none; +} + +/* + * A better looking default horizontal rule + */ + +hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + margin: 1em 0; + padding: 0; +} + +/* + * Remove the gap between audio, canvas, iframes, + * images, videos and the bottom of their containers: + * https://github.com/h5bp/html5-boilerplate/issues/440 + */ + +audio, +canvas, +iframe, +img, +svg, +video { + vertical-align: middle; +} + +/* + * Remove default fieldset styles. + */ + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +/* + * Allow only vertical resizing of textareas. + */ + +textarea { + resize: vertical; +} + +/* ========================================================================== + Author's custom styles + ========================================================================== */ + +/* ========================================================================== + Helper classes + ========================================================================== */ + +/* + * Hide visually and from screen readers + */ + +.hidden, +[hidden] { + display: none !important; +} + +/* + * Hide only visually, but have it available for screen readers: + * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility + * + * 1. For long content, line feeds are not interpreted as spaces and small width + * causes content to wrap 1 word per line: + * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe + */ + +.visually-hidden { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + /* 1 */ +} + +/* + * Extends the .visually-hidden class to allow the element + * to be focusable when navigated to via the keyboard: + * https://www.drupal.org/node/897638 + */ + +.visually-hidden.focusable:active, +.visually-hidden.focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + white-space: inherit; + width: auto; +} + +/* + * Hide visually and from screen readers, but maintain layout + */ + +.invisible { + visibility: hidden; +} + +/* + * Clearfix: contain floats + * + * The use of `table` rather than `block` is only necessary if using + * `::before` to contain the top-margins of child elements. + */ + +.clearfix::before, +.clearfix::after { + content: ""; + display: table; +} + +.clearfix::after { + clear: both; +} + +/* ========================================================================== + EXAMPLE Media Queries for Responsive Design. + These examples override the primary ('mobile first') styles. + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 35em) { + /* Style adjustments for viewports that meet the condition */ +} + +@media print, + (min-resolution: 1.25dppx), + (min-resolution: 120dpi) { + /* Style adjustments for high resolution devices */ +} + +/* ========================================================================== + Print styles. + Inlined to avoid the additional HTTP request: + https://www.phpied.com/delay-loading-your-print-css/ + ========================================================================== */ + +@media print { + *, + *::before, + *::after { + background: #fff !important; + color: #000 !important; + /* Black prints faster */ + box-shadow: none !important; + text-shadow: none !important; + } + + a, + a:visited { + text-decoration: underline; + } + + a[href]::after { + content: " (" attr(href) ")"; + } + + abbr[title]::after { + content: " (" attr(title) ")"; + } + + /* + * Don't show links that are fragment identifiers, + * or use the `javascript:` pseudo protocol + */ + a[href^="#"]::after, + a[href^="javascript:"]::after { + content: ""; + } + + pre { + white-space: pre-wrap !important; + } + + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + + tr, + img { + page-break-inside: avoid; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } +} + diff --git a/Services/Website/Resources/Static/favicon.ico b/Services/Website/Resources/Static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..be74abd69ad6a32de7375df13cab9354798e328f GIT binary patch literal 766 zcmc(dze~eV5XUd2fg&jH87YDYDQKxq1{4b-_ydP-wqS9vgGh17QXQQAwOE{VaBvi* zmu^z9t({y-&1ey8Y_x;?x+`BviV9;aRjMgZ8M*!jgkRrFqSI9;F zHyfX@Az|AvVmn~YWWZP`0&JWEY~BFm?*Vq}VD7&_%x%MP$p`D`4JMC!K|B7pt?Mmp zUJAB7rxMXS6=!P+AtLU9V)J#61WPxwipRXCHO{BJ`l{m53#=t97a!znv~vfmr|AaP zRGIT7#0FyJy3Z*hL{GQp-0TRhX8UzZ)+>%?mK0^goaX4Q;xkoG_P!lF_+$k;;moo~MP{oen-d(OT0oZo$ZzjM$1-E*GjiAI^|v4I7_3=9lx2Kq=d`u^#E zCkQ}aCz}pY3=AC44Uk%QpD?bZhX)E73`U%~7cQl85yvVXk(g;IUxeEq-gAn`O#OGr z&lO(^T_yYT*6U?-xaqF>yQNy04owaZTcEWi85lr72tIo@UX7J#rUIBw~lJs={CYso;p)qnluna#RzIviF z@XZ9aXw3jLGsYiRA9QXSBQ0g8nvapf3GwJckKb+73+ey{f{A?pDZc1rRRp8J`eE{{ z12JxDMe;-X+2{~b8vxQ@V~^p|pRo&j-hK3?0HH28U;1{w2ngwys?NrTti=FA`$T!T zWQv0~2?Ezt()hh=Kb*P7^9=EYtP-u>vPa~wP}kMQgxcDviA`8}&)>U$C)PUNAT?UbMk-poy=~^hjqHKnI3X=cZp-P;XD9*pla$REW99w&It*Gp-~k}4%869y z&75l9QBIC77(y%a7F0CpRI&mOI)cS|2G^(9c}R(dLg1mseUuF!Fip+R_aKwl4%%uj zlX2n}oG-heo>Vvj?eAXyA{Zm*=ok=ZaL!#;5M1SRAVx>y)(5UJAJiEcBVWyKd1#y?aD-LnG zMC`33$8jx0dMU7WPc|M#u(wXi0@GOU?K}P^?X3|lR7|v=Vg^wZDpj2x*3|TGKydpR zU&=<7K9K64`B2||j*v}!6l4rM7?yl-rb=#F2`gn$il9VYQWGV$TuoDx$aZ)EpYE|T5oqmIZ63g_SCfJl zigFx_JCR8xHRqU%GC!Uph;`N2VafmeCiuy_~JBx;+d|yA`?-``$ zhxc*=UqF&=VcN!=!6Mvdc`vdMB}S@H6mb6}R5=v^$0bKcrSg|eD>sgOqy1k2yyl%& zUe>(103E_Fj1uq#@8g4s41<|rHi>NOHhSwSA7`dOQP{oc`Xw0Pc*N+%8U68yES-U; zZD2x$?}_*Qlbf#CvoPL?P#r>%tuqvbd{Uvle~>rZ)hFUM?dw5rky$kpLCpTdLll3^ zXZr$pIZO43yL03_r_{_`U&f3_di1vvm2=Dq5u%(@TMZvOn#L}zr;nyajZs%_#BoTb z{)KobkGI6J!cuc{b+j>#?I?=FV`jjuhNE%wA1jIRvPo>r3A$G~rM`w>C!7g58si{? zLQ8)ZXH9z;0)LdYVFei{_T?E!?Y#W{_?@Q?CH@sWk+`(*B>21SVO;MNlO-4sPAqHs zqy^eyDN`i1?}%)k<>Cj?FX!;lSi5Ohfw!PKR1^^YQsn-LFe0R<_2FS$%ELqUkH_)w z3xM!w@1`%GS+_XWq>V_Z?`qzA;z{m-5L9hvCet0g_B=k0x`a{VGG#ftNCb50q?<5t z33^P3jw0V1Yb^JnB^W$+Iv<9Zer(Qsr5}&WRqX3w0VTzegy62fX72hEiI(=sa26q= z)wefEn@1-cNoK+T!UpR>VX$V%IZ*T9T{ne`3EmK=1ULUlS=CxZ3jZ6J2X(B__#H}u zR%riaMEc0t8(8%S(rt8m2$$>%ydzm(N4`JVyEXDAgp`_p2h!NOPYAuf^)Yh_+mH@7`kD{Hn!h2huDs=X zG~T&{anJTiR^{xs*_pZ^Bw5JQaHvkMSh`%)x#y2KN~lGxhZQ066kuiDyDczU=) z&fBobH^_VT$}MzY+-cvejN{j`=Bom;LC^dg61vtWGqz?bUbO0IUbMfUiov zhNP$Hm~$v4zeNX~gU3bVpP1hFP!##}?w8}veUZnmEhf$_RwyTh&?jk`lS)iod{%nN zCDwMY1g2L=AyvH$;l_|j)f4ZjHa``o@d{p7hmQ_{n8C-vuwZ0&mb`0^NU32E-B>SILeRoh9rqKOJZQ{fy^efaIdh8pI0Ccek}X1~4_dfdSAXl^`xW&0 zRLQiIbI$6*qCn{BZ;QMRp;mu3iCD9aa7zZ)4Drx)Q5(XR-wyg>Pj|((eyp6WbVgF$ z=f4MUa-G+&y!!%YktS=44wUQ180A0xVH0AVf?cT3u_f>9t`$|XiE4P}Ev?YOBuK&q@s$TeB3c?&tYER|*Y-)x?(ci?lb^UT`!w*o<%7=&3B4=_t_f z)U}wHZp`VGeJN}EitL8{%XAY#(Z8E_{F}+bnAB$YR`Gsj@|E#tT`IzpYT1_L9eCzT z+iJ^DdaFItX#@mUk>O!+%@FPACreI`7sOT6VT)x$MROOPuQ|SVL*7@#L{NrI|LXLF z1ED|(BYOfO9CjOjWdPk!xbW(usP75SZf1!e$u zS5lD(B2ID&qstc|OmUOAcxZh}V%`mU4=KyXLhdlw!tV<6* z!{({uC}VTS)PvsluA)2QJxqiYg9I5Uw?1unxjts~B&sILmy54e?>c9C*p0nT$JI4FWg+m7gHWD^F3*qM$hS1!6qWsQ6%huv8 zqayPU6s0#dn9h1fzuMO#EGT8y+m#?RTDO%#2U-UL=jlH^$pRN5HoiS)w;sB@IGf#2 z#ZEZ7$jC=^nM*y>UE+BGJk;cO7O>w|W5-s5@b*+-(K_K=ae?I+rj7d-MqL-{cMpB8K z3%N%-Mg;hNFnbRM_p3Ua(Nb2hVjc`*CKg3fhd zK0taD|LFDphqeZHlgbJLoj&Oj{xOlIwJB_cCJP2V8-W^Qg`xFT@lrNhDZn$Tbf)ck~d=i9dg z%e8`DpHvS-%kx_fo<6%YroouuIX2L*)NSzLht1l4SnMnf$z^)`tYwIZ#5+B#RZXuT z7$zh^N0=V#BToN#l4CAT$OpSz8(Vt6vNu(|{=wXs>Bqg6mR7g6aKzMJV`8k`F_B}q zSSYfdE6_?(0C>48wb@fBuYT>b;nG&DIkk0_ey>j<5hmC3OGww&bQiLQ{bc%@kGV7d z&|pHlZxX!hYS{@l|7@t=4p}RiuUaH5H7NxD!nV|KG|U?jA82`&_Bmv6FB zr8HvTl(3*bmo#xpQz>&EWjeIWt|BIi%!CZ5Du?ArFJBd}4D`+lMfAvSGk;B^DcCeW z*N6s~OA`TMK}MnLeuvWm(3I0d%w|{+rBQ6JZ)(pNqE=|*R>1#wcU1^{7w&%nAmUKp z*0RsNE$jO0#B8SvDk$sL^!@PUxKDVZWFVd{&U@r7ue3u!x%(Ub@0{0{kNwJ3m&~t@ zhK$v$xyvmjVJGQ7zl4#X`&tTEjUJ)DPuAre+obOLcP{GUp1WRf6<&SO00boVGwx{} z$C6~jZvWsSU$33wN`3ih6q%X8-Y?!AYXag}(!?_Mdxv(fwVLIDfr4SO4?DuE;Vc18 zV4;LrlF2rm%SWoA{ny?n6h=44=3@RL8=AWe2#QR08P}QUKu(pZEZZQ?u3B>J{Q;MX zh#gLS_au{=AR5bd6qga)JjV=w9`KY}fWNy~Ka0YWymJO{=Yn>mfg2I&fpZ=h9EvF5 zt9qEEYa-1=kf6Hwy8c diff --git a/Services/Website/Resources/Static/index.html b/Services/Website/Resources/Static/index.html new file mode 100644 index 0000000..f3add49 --- /dev/null +++ b/Services/Website/Resources/Static/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Hello world! This is HTML5 Boilerplate.

+ + + + + diff --git a/Services/Website/Resources/Static/js/app.js b/Services/Website/Resources/Static/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/robots.txt b/Services/Website/Resources/Static/robots.txt new file mode 100644 index 0000000..51d2d2e --- /dev/null +++ b/Services/Website/Resources/Static/robots.txt @@ -0,0 +1,5 @@ +# https://www.robotstxt.org/ + +# Allow crawling of all content +User-agent: * +Disallow: diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest new file mode 100644 index 0000000..222ae16 --- /dev/null +++ b/Services/Website/Resources/Static/site.webmanifest @@ -0,0 +1,12 @@ +{ + "short_name": "", + "name": "", + "icons": [{ + "src": "icon.png", + "type": "image/png", + "sizes": "192x192" + }], + "start_url": "/?utm_source=homescreen", + "background_color": "#fafafa", + "theme_color": "#fafafa" +} diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index d5df224..3561f8b 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -1,24 +1,37 @@ import Configuration import Hummingbird import Logging +import WebsiteCore -/// Build application -/// - Parameter reader: configuration reader +/// Builds the website application. +/// +/// Reads the log level, server name, and static files location from the configuration, +/// then assembles the router, server configuration, and logger. +/// - Parameter reader: the configuration reader the values are read from. +/// - Returns: the configured application, ready to run as a service. +/// - Throws: an error if the router fails to build. func application( reader: ConfigReader ) async throws -> some ApplicationProtocol { let logLevel = reader.string( - forKey: "log.level", + forKey: .Log.level, as: Logger.Level.self, default: .info ) let serverName = reader.string( - forKey: "http.serverName", - default: "LoudWebsite" + forKey: .HTTP.serverName, + default: .Server.name + ) + let staticFilesPath = reader.string( + forKey: .Path.staticFiles, + default: .Path.staticResources ) return Application( - router: try router(), + router: router( + staticFilesPath: staticFilesPath, + logLevel: logLevel + ), configuration: ApplicationConfiguration( reader: reader.scoped(to: "http") ), @@ -34,7 +47,11 @@ func application( // Request context used by application private typealias AppRequestContext = BasicRequestContext -/// Build logger +/// Builds the application's logger. +/// - Parameters: +/// - serverName: the label applied to the logger. +/// - logLevel: the minimum level the logger emits. +/// - Returns: the configured logger. private func logger( serverName: String, logLevel: Logger.Level @@ -46,16 +63,26 @@ private func logger( return logger } -/// Build router -private func router() throws -> Router { +/// Builds the application's router. +/// +/// Registers the request-logging middleware and the static file middleware that serves +/// the contents of `staticFilesPath`. +/// - Parameters: +/// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - logLevel: the level the request-logging middleware logs at. +/// - Returns: the configured router. +private func router( + staticFilesPath: String, + logLevel: Logger.Level +) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { - LogRequestsMiddleware(.info) - } - - router.get("/") { _, _ in - return "Hello!" + LogRequestsMiddleware(logLevel) + FileMiddleware( + staticFilesPath, + searchForIndexHtml: false + ) } return router diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index c46a698..335916c 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -14,7 +14,7 @@ struct App { allowMissing: true ), InMemoryProvider(values: [ - "http.serverName": "LoudWebsite" + .HTTP.serverName: .HTTP.serverName ]), ] ) diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift new file mode 100644 index 0000000..656f369 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -0,0 +1,166 @@ +import Foundation + +/// A static file shipped with the website service. +/// +/// Each case identifies a file stored under the static files root (the `Resources/Static` +/// directory) and served by Hummingbird's `FileMiddleware` middleware. +enum StaticFile: CaseIterable, Sendable { + /// The `js/app.js` script. + case appJS + /// The `404.html` error page. + case errorHTML + /// The `favicon.ico` icon. + case faviconICO + /// The `icon.png` icon. + case iconPNG + /// The `icon.svg` icon. + case iconSVG + /// The `index.html` landing page. + case indexHTML + /// The `robots.txt` crawler directives. + case robotsTXT + /// The `site.webmanifest` web application manifest. + case siteWebmanifest + /// The `css/style.css` stylesheet. + case styleCSS +} + +// MARK: - Enumerations + +extension StaticFile { + /// A file extension used by a ``StaticFile``. + enum Extension: String, Sendable { + /// A Cascading Style Sheets file. + case css + /// A HyperText Markup Language file. + case html + /// A JavaScript file. + case js + /// A Portable Network Graphics image. + case png + /// A Windows icon image. + case ico + /// A Scalable Vector Graphics image. + case svg + /// A plain text file. + case txt + /// A web application manifest file. + case webmanifest + } +} + +// MARK: - Extensions + +extension StaticFile { + + // MARK: Computed + + /// The file's content type. + var contentType: String { + switch fileExtension { + case .css: "text/css" + case .html: "text/html" + case .js: "text/javascript" + case .png: "image/png" + case .ico: "image/vnd.microsoft.icon" + case .svg: "image/svg+xml" + case .txt: "text/plain" + case .webmanifest: "application/manifest+json" + } + } + + /// The file's extension. + var fileExtension: Extension { + switch self { + case .styleCSS: .css + case .appJS: .js + case .errorHTML, + .indexHTML: .html + case .faviconICO: .ico + case .iconPNG: .png + case .iconSVG: .svg + case .robotsTXT: .txt + case .siteWebmanifest: .webmanifest + } + } + + /// The file's name, without extension. + var fileName: String { + switch self { + case .appJS: "app" + case .errorHTML: "404" + case .faviconICO: "favicon" + case .iconPNG, + .iconSVG: "icon" + case .indexHTML: "index" + case .robotsTXT: "robots" + case .siteWebmanifest: "site" + case .styleCSS: "style" + } + } + + /// The path relative to the static files root (e.g. `"css/style.css"`). + /// + /// This also matches the URL path the file is served at by `FileMiddleware`. + var relativePath: String { + let file = String( + format: Constant.Format.file, + fileName, + fileExtension.rawValue + ) + + return subdirectory + .map { .init( + format: Constant.Format.path, + $0, + file + )} ?? file + } + + // MARK: Methods + + /// Resolves the file's path against the given base directory. + /// + /// - Parameter basePath: the directory the static files are served from. + /// - Returns: the path to the file, relative to the `basePath` path. + public func path( + relativeTo basePath: String + ) -> String { + guard !basePath.isEmpty else { + return relativePath + } + + return .init( + format: Constant.Format.path, + basePath, + relativePath + ) + } + +} + +// MARK: - Helpers + +private extension StaticFile { + + // MARK: Computed + + /// The sub-directory within the static root that holds the file, if any. + var subdirectory: String? { + switch self { + case .appJS: "js" + case .styleCSS: "css" + default: nil + } + } + +} + +// MARK: - Constants + +private enum Constant { + enum Format { + static let file = "%@.%@" + static let path = "%@/%@" + } +} diff --git a/Services/Website/Sources/Library/Library.swift b/Services/Website/Sources/Library/Library.swift deleted file mode 100644 index 139597f..0000000 --- a/Services/Website/Sources/Library/Library.swift +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift new file mode 100644 index 0000000..94a8cbf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension AbsoluteConfigKey { + /// A namespace for the HTTP server configuration keys, as absolute keys. + public enum HTTP { + /// The absolute configuration key for the host the server binds to. + public static let host: AbsoluteConfigKey = .init(.HTTP.host) + /// The absolute configuration key for the port the server listens on. + public static let port: AbsoluteConfigKey = .init(.HTTP.port) + /// The absolute configuration key for the server's name. + public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName) + } + /// A namespace for the logging configuration keys, as absolute keys. + public enum Log { + /// The absolute configuration key for the minimum log level. + public static let level: AbsoluteConfigKey = .init(.Log.level) + } + /// A namespace for the path configuration keys, as absolute keys. + public enum Path { + /// The absolute configuration key for the directory the static files are served from. + public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles) + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift new file mode 100644 index 0000000..55c6f7d --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension ConfigKey { + /// A namespace for the HTTP server configuration keys. + public enum HTTP { + /// The configuration key for the host the server binds to. + public static let host: ConfigKey = "http.host" + /// The configuration key for the port the server listens on. + public static let port: ConfigKey = "http.port" + /// The configuration key for the server's name. + public static let serverName: ConfigKey = "http.serverName" + } + /// A namespace for the logging configuration keys. + public enum Log { + /// The configuration key for the minimum log level. + public static let level: ConfigKey = "log.level" + } + /// A namespace for the path configuration keys. + public enum Path { + /// The configuration key for the directory the static files are served from. + public static let staticFiles: ConfigKey = "path.staticFiles" + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift new file mode 100644 index 0000000..58973bf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift @@ -0,0 +1,18 @@ +import Configuration + +extension ConfigValue { + /// A namespace for the HTTP server's default configuration values. + public enum HTTP { + /// The default host the server binds to. + public static let host: ConfigValue = .init(stringLiteral: "127.0.0.1") + /// The default port the server listens on. + public static let port: ConfigValue = .init(stringLiteral: "0") + /// The default server name. + public static let serverName: ConfigValue = .init(stringLiteral: .Server.name) + } + /// A namespace for the logging default configuration values. + enum Log { + /// The default minimum log level. + public static let level: ConfigValue = .init(stringLiteral: "trace") + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..22a1e0f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -0,0 +1,12 @@ +extension String { + /// A namespace for well-known path string constants. + public enum Path { + /// The directory, relative to the working directory, that the website's static files are served from. + public static let staticResources = "Resources/Static" + } + /// A namespace for the server string constants. + public enum Server { + /// The website server's name. + public static let name = "LoudWebsite" + } +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 379de9f..c784d94 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -1,34 +1,69 @@ import Configuration +import Foundation import Hummingbird import HummingbirdTesting import Logging import Testing @testable import Website +@testable import WebsiteCore -private let reader = ConfigReader(providers: [ - InMemoryProvider(values: [ - "http.host": "127.0.0.1", - "http.port": "0", - "log.level": "trace", - ]) -]) - -@Suite +@Suite("App executable") struct AppTests { - @Test - func hello() async throws { + + // MARK: Constants + + // Absolute path to the package's "Resources/Static" folder, derived from this + // file's location so the static files resolve regardless of the working directory. + private let staticFilesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/App + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root + .appendingPathComponent(.Path.staticResources) + .path + + // MARK: Functional tests + + @Test(arguments: StaticFile.allCases) + func `static files to be served`( + staticFile file: StaticFile + ) async throws { let app = try await application( - reader: reader + reader: reader( + staticFilesPath: staticFilesPath + ) ) try await app.test(.router) { client in try await client.execute( - uri: "/", + uri: "/\(file.relativePath)", method: .get ) { response in - #expect(response.body == ByteBuffer(string: "Hello!")) + #expect(response.status == .ok) + #expect(response.headers[.contentType] == file.contentType) } } } + +} + +// MARK: - Helpers + +private extension AppTests { + + // MARK: Methods + + func reader( + staticFilesPath: String + ) -> ConfigReader { + ConfigReader(providers: [ + InMemoryProvider(values: [ + .HTTP.host: .HTTP.host, + .HTTP.port: .HTTP.port, + .Log.level: .Log.level, + .Path.staticFiles: .init(stringLiteral: staticFilesPath), + ]) + ]) + } + } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift new file mode 100644 index 0000000..1ac3872 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing + +@testable import WebsiteCore + +@Suite("StaticFile enumeration") +struct StaticFileTests { + + // MARK: Type aliases + + typealias File = StaticFile + typealias FileExtension = StaticFile.Extension + + // MARK: Computed tests + + @Test(arguments: zip( + File.allCases, + Self.contentTypes + )) + func `content type`( + for file: File, + expects contentType: String + ) { + #expect(file.contentType == contentType) + } + + @Test(arguments: zip( + File.allCases, + Self.fileExtensions + )) + func `file extension`( + for file: File, + expects `extension`: FileExtension + ) { + #expect(file.fileExtension == `extension`) + } + + @Test(arguments: zip( + File.allCases, + Self.fileNames + )) + func `file name`( + for file: File, + expects fileName: String + ) { + #expect(file.fileName == fileName) + } + + @Test(arguments: zip( + File.allCases, + Self.relativePaths + )) + func `relative path`( + for file: File, + expects relativePath: String + ) { + #expect(file.relativePath == relativePath) + } + + // MARK: Method tests + + @Test(arguments: [ + "", + ".", + "Resources/Static" + ]) + func `path relative to`( + _ basePath: String + ) { + for file in File.allCases { + let pathRelativeToBasePath = file.path(relativeTo: basePath) + + if basePath.isEmpty { + #expect(pathRelativeToBasePath == file.relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)") + } + } + } + + // MARK: CaseIterable tests + + @Test + func `all cases`() { + #expect(File.allCases.count == 9) + } + +} + +// MARK: - Helpers + +private extension StaticFileTests { + + // MARK: Constants + + static let contentTypes: [String] = [ + "text/javascript", + "text/html", + "image/vnd.microsoft.icon", + "image/png", + "image/svg+xml", + "text/html", + "text/plain", + "application/manifest+json", + "text/css" + ] + static let fileExtensions: [FileExtension] = [ + .js, + .html, + .ico, + .png, + .svg, + .html, + .txt, + .webmanifest, + .css + ] + static let fileNames: [String] = [ + "app", + "404", + "favicon", + "icon", + "icon", + "index", + "robots", + "site", + "style" + ] + static let relativePaths: [String] = [ + "js/app.js", + "404.html", + "favicon.ico", + "icon.png", + "icon.svg", + "index.html", + "robots.txt", + "site.webmanifest", + "css/style.css" + ] + +} diff --git a/Services/Website/Tests/Library/LibraryTest.swift b/Services/Website/Tests/Library/LibraryTest.swift deleted file mode 100644 index 8b13789..0000000 --- a/Services/Website/Tests/Library/LibraryTest.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Services/Website/Website.xctestplan b/Services/Website/Website.xctestplan new file mode 100644 index 0000000..5156232 --- /dev/null +++ b/Services/Website/Website.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "F3231E68-357B-4ED7-98E9-F5767AB17024", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "Website", + "name" : "Website" + }, + "testInteropMode" : "complete" + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteTests", + "name" : "WebsiteTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteCoreTests", + "name" : "WebsiteCoreTests" + } + } + ], + "version" : 1 +} -- 2.54.0 From f5f14eb97c9afe34577c09d8f64539266abd5158 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 27 Jun 2026 12:16:50 +0000 Subject: [PATCH 005/117] File not found middleware for the Website service (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to add a `NotFoundMiddleware` middleware to the Website service so requests matching neither a route nor a static file return a custom 404.html page with a 404 Not Found status. Also enables index.html fallback so the landing page is served at the site root. To provider further details about the work done: * NotFoundMiddleware — intercepts the `.notFound` error from the `FileMiddleware` middleware and serves the preloaded error page with the correct content type; all other errors propagate. Falls back to a minimal body if the file is missing. * Router — wires the `NotFoundMiddleware` middleware ahead of the `FileMiddleware` and enables searchForIndexHtml. * Package — adds the **Hummingbird** product to the Library target. * Tooling — sets a custom working directory in the Xcode scheme; removes the unused `pkg-deps` target from the `Makefile` file. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/4 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcshareddata/xcschemes/Website.xcscheme | 3 +- Services/Website/Makefile | 4 - Services/Website/Package.swift | 4 + Services/Website/Sources/App/App+build.swift | 8 +- .../Middlewares/NotFoundMiddleware.swift | 80 +++++++++++++++++++ Services/Website/Tests/App/AppTests.swift | 63 ++++++++++++++- 6 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme index 01a4a46..24b3ac5 100644 --- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -50,7 +50,8 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - useCustomWorkingDirectory = "NO" + useCustomWorkingDirectory = "YES" + customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" diff --git a/Services/Website/Makefile b/Services/Website/Makefile index 98cb6b7..30aa03f 100644 --- a/Services/Website/Makefile +++ b/Services/Website/Makefile @@ -41,10 +41,6 @@ pkg-clean: ## Remove the Swift build artifacts pkg-reset: ## Resets the complete SPM cache/build folder @swift package reset -.PHONY: pkg-deps -pkg-deps: ## Lists the SPM package dependencies - @swift package show-dependencies - .PHONY: pkg-outdated pkg-outdated: ## Lists the SPM package dependencies that can be updated @swift package update --dry-run diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index e44f6bb..0d09443 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -55,6 +55,10 @@ let package = Package( name: "Configuration", package: "swift-configuration" ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), ], path: "Sources/Library" ), diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 3561f8b..41d2fee 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -65,8 +65,9 @@ private func logger( /// Builds the application's router. /// -/// Registers the request-logging middleware and the static file middleware that serves -/// the contents of `staticFilesPath`. +/// Registers, in order, the request-logging middleware, the not-found middleware that serves +/// the error page, and the static file middleware that serves the contents of `staticFilesPath` +/// (falling back to `index.html` for directory requests). /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - logLevel: the level the request-logging middleware logs at. @@ -79,9 +80,10 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) + NotFoundMiddleware(staticFilesPath) FileMiddleware( staticFilesPath, - searchForIndexHtml: false + searchForIndexHtml: true ) } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift new file mode 100644 index 0000000..d82fbfe --- /dev/null +++ b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift @@ -0,0 +1,80 @@ +import Foundation +import Hummingbird +import NIOCore + +/// 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 preloaded +/// error page and a `404 Not Found` status. +public struct NotFoundMiddleware { + + // MARK: Properties + + /// The body of the error page served on a not-found response. + private let page: ByteBuffer + + // MARK: Initializers + + /// Creates a middleware that serves the error page (`404.html`) from the static files folder. + /// + /// The page is read once, at construction. A minimal fallback body is used when the file is + /// missing. + /// - Parameter staticFilesPath: the folder, relative to the working directory, the static files are served from. + public init( + _ staticFilesPath: String + ) { + let path = StaticFile.errorHTML.path(relativeTo: staticFilesPath) + + if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + self.init(page: .init(bytes: data)) + } else { + self.init(page: .init(string: "404 Not Found")) + } + } + + /// Creates a middleware that serves the given error page on a not-found response. + /// - Parameter page: the body of the error page. + init( + page: ByteBuffer + ) { + self.page = page + } + +} + +// MARK: - RouterMiddleware + +extension NotFoundMiddleware: RouterMiddleware { + + // MARK: Functions + + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + do { + return try await next(request, context) + } catch let error { + // Only intercept "not found"; let every other error propagate. + guard + let responseError = error as? any HTTPResponseError, + responseError.status == .notFound + else { + throw error + } + + var headers = HTTPFields() + + headers[.contentType] = StaticFile.errorHTML.contentType + + return Response( + status: .notFound, + headers: headers, + body: .init(byteBuffer: page) + ) + } + } + +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index c784d94..ec7dfb1 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -3,6 +3,7 @@ import Foundation import Hummingbird import HummingbirdTesting import Logging +import NIOCore import Testing @testable import Website @@ -21,9 +22,30 @@ struct AppTests { .deletingLastPathComponent() // package root .appendingPathComponent(.Path.staticResources) .path - + // MARK: Functional tests + @Test + func `landing page to be served at root`() async throws { + let file: StaticFile = .indexHTML + let app = try await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == file.contentType) + #expect(response.body == data(of: file)) + } + } + } + @Test(arguments: StaticFile.allCases) func `static files to be served`( staticFile file: StaticFile @@ -45,14 +67,47 @@ struct AppTests { } } + @Test + func `error page to be served when not found`() async throws { + let file: StaticFile = .errorHTML + let app = try await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == file.contentType) + #expect(response.body == data(of: file)) + } + } + } + } // MARK: - Helpers private extension AppTests { - + // MARK: Methods - + + func data( + of file: StaticFile + ) -> ByteBuffer { + let url = URL(fileURLWithPath: file.path(relativeTo: staticFilesPath)) + + guard let data = try? Data(contentsOf: url) else { + return ByteBuffer() + } + + return ByteBuffer(bytes: data) + } + func reader( staticFilesPath: String ) -> ConfigReader { @@ -65,5 +120,5 @@ private extension AppTests { ]) ]) } - + } -- 2.54.0 From a621ada0bf9d3fc1f83b02522a84a78c94d6f363 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 05:07:19 +0000 Subject: [PATCH 006/117] HTML rendering support for the Website service (#5) This PR contains the work done to replace the use of static _HTML_ files with type-safe HTML rendered server-side via **Elementary** through **Hummingbird**. To provide further details about the work done: * Added the **Elementary** dependencies. * Added the `IndexPage` and `ErrorPage` pages, ported from the old HTML boilerplate; removed the static files. * Added the `RootController` controller serving GET / using the `IndexPage` page, wired into the router. * Reworked the `NotFoundMiddleware` middleare to render `ErrorPage` page directly; non-notFound errors still propagate. * the `FileMiddleware` middleware no longer searches for any static `index.html` file. * Simplified the `StaticFile` enumeration, dropped unused constants and now-unnecessary throws. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/5 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Package.swift | 28 +++++++ Services/Website/Resources/Static/404.html | 62 --------------- Services/Website/Resources/Static/index.html | 33 -------- Services/Website/Sources/App/App+build.swift | 18 ++--- Services/Website/Sources/App/App.swift | 2 +- .../Internal/Enumerations/StaticFile.swift | 42 +--------- .../Library/Internal/Pages/ErrorPage.swift | 79 +++++++++++++++++++ .../Library/Internal/Pages/IndexPage.swift | 63 +++++++++++++++ .../Public/Controllers/RootController.swift | 71 +++++++++++++++++ .../Extensions/ConfigValue+Constants.swift | 9 --- .../Middlewares/NotFoundMiddleware.swift | 72 +++++++---------- Services/Website/Tests/App/AppTests.swift | 58 +++++--------- .../Enumerations/StaticFileTests.swift | 10 +-- .../Cases/Internal/Pages/ErrorPageTests.swift | 20 +++++ .../Cases/Internal/Pages/IndexPageTests.swift | 21 +++++ .../Controllers/RootControllerTests.swift | 39 +++++++++ .../Middlewares/NotFoundMiddlewareTests.swift | 77 ++++++++++++++++++ 17 files changed, 460 insertions(+), 244 deletions(-) delete mode 100644 Services/Website/Resources/Static/404.html delete mode 100644 Services/Website/Resources/Static/index.html create mode 100644 Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/IndexPage.swift create mode 100644 Services/Website/Sources/Library/Public/Controllers/RootController.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 0d09443..8b81b82 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -19,6 +19,14 @@ let package = Package( ) ], dependencies: [ + .package( + url: "https://github.com/elementary-swift/elementary.git", + from: "0.6.0" + ), + .package( + url: "https://github.com/hummingbird-community/hummingbird-elementary.git", + from: "0.3.0" + ), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" @@ -55,10 +63,18 @@ let package = Package( name: "Configuration", package: "swift-configuration" ), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "Hummingbird", package: "hummingbird" ), + .product( + name: "HummingbirdElementary", + package: "hummingbird-elementary" + ), ], path: "Sources/Library" ), @@ -77,6 +93,18 @@ let package = Package( name: "WebsiteCoreTests", dependencies: [ .byName(name: "WebsiteCore"), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), ], path: "Tests/Library" ), diff --git a/Services/Website/Resources/Static/404.html b/Services/Website/Resources/Static/404.html deleted file mode 100644 index 260cc4c..0000000 --- a/Services/Website/Resources/Static/404.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - Page Not Found - - - - - -

Page Not Found

-

Sorry, but the page you were trying to view does not exist.

- - - - diff --git a/Services/Website/Resources/Static/index.html b/Services/Website/Resources/Static/index.html deleted file mode 100644 index f3add49..0000000 --- a/Services/Website/Resources/Static/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Hello world! This is HTML5 Boilerplate.

- - - - - diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 41d2fee..2358bb5 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -9,10 +9,9 @@ import WebsiteCore /// then assembles the router, server configuration, and logger. /// - Parameter reader: the configuration reader the values are read from. /// - Returns: the configured application, ready to run as a service. -/// - Throws: an error if the router fails to build. func application( reader: ConfigReader -) async throws -> some ApplicationProtocol { +) async -> some ApplicationProtocol { let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -65,9 +64,9 @@ private func logger( /// Builds the application's router. /// -/// Registers, in order, the request-logging middleware, the not-found middleware that serves -/// the error page, and the static file middleware that serves the contents of `staticFilesPath` -/// (falling back to `index.html` for directory requests). +/// Registers the request-logging middleware, the not-found middleware that serves the error +/// page, and the static file middleware that serves the contents of `staticFilesPath`, then +/// adds the `RootController` routes that render the landing page. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - logLevel: the level the request-logging middleware logs at. @@ -80,12 +79,11 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) - NotFoundMiddleware(staticFilesPath) - FileMiddleware( - staticFilesPath, - searchForIndexHtml: true - ) + NotFoundMiddleware() + FileMiddleware(staticFilesPath) } + router.addRoutes(RootController().routes) + return router } diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index 335916c..cf3e8bf 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -19,7 +19,7 @@ struct App { ] ) - let app = try await application( + let app = await application( reader: reader ) diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 656f369..0c72430 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,5 +1,3 @@ -import Foundation - /// A static file shipped with the website service. /// /// Each case identifies a file stored under the static files root (the `Resources/Static` @@ -7,16 +5,12 @@ import Foundation enum StaticFile: CaseIterable, Sendable { /// The `js/app.js` script. case appJS - /// The `404.html` error page. - case errorHTML /// The `favicon.ico` icon. case faviconICO /// The `icon.png` icon. case iconPNG /// The `icon.svg` icon. case iconSVG - /// The `index.html` landing page. - case indexHTML /// The `robots.txt` crawler directives. case robotsTXT /// The `site.webmanifest` web application manifest. @@ -32,8 +26,6 @@ extension StaticFile { enum Extension: String, Sendable { /// A Cascading Style Sheets file. case css - /// A HyperText Markup Language file. - case html /// A JavaScript file. case js /// A Portable Network Graphics image. @@ -59,7 +51,6 @@ extension StaticFile { var contentType: String { switch fileExtension { case .css: "text/css" - case .html: "text/html" case .js: "text/javascript" case .png: "image/png" case .ico: "image/vnd.microsoft.icon" @@ -74,8 +65,6 @@ extension StaticFile { switch self { case .styleCSS: .css case .appJS: .js - case .errorHTML, - .indexHTML: .html case .faviconICO: .ico case .iconPNG: .png case .iconSVG: .svg @@ -88,11 +77,9 @@ extension StaticFile { var fileName: String { switch self { case .appJS: "app" - case .errorHTML: "404" case .faviconICO: "favicon" case .iconPNG, .iconSVG: "icon" - case .indexHTML: "index" case .robotsTXT: "robots" case .siteWebmanifest: "site" case .styleCSS: "style" @@ -103,18 +90,10 @@ extension StaticFile { /// /// This also matches the URL path the file is served at by `FileMiddleware`. var relativePath: String { - let file = String( - format: Constant.Format.file, - fileName, - fileExtension.rawValue - ) + let file = "\(fileName).\(fileExtension.rawValue)" return subdirectory - .map { .init( - format: Constant.Format.path, - $0, - file - )} ?? file + .map { "\($0)/\(file)" } ?? file } // MARK: Methods @@ -129,12 +108,8 @@ extension StaticFile { guard !basePath.isEmpty else { return relativePath } - - return .init( - format: Constant.Format.path, - basePath, - relativePath - ) + + return "\(basePath)/\(relativePath)" } } @@ -155,12 +130,3 @@ private extension StaticFile { } } - -// MARK: - Constants - -private enum Constant { - enum Format { - static let file = "%@.%@" - static let path = "%@/%@" - } -} diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift new file mode 100644 index 0000000..b59df57 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -0,0 +1,79 @@ +import Elementary + +/// The HTML page rendered for a not-found response. +struct ErrorPage: HTMLDocument, Sendable { + + // MARK: Document + + /// The page's content. + var body: some HTML { + h1 { "Page Not Found" } + p { "Sorry, but the page you were trying to view does not exist." } + } + + /// The metadata and inline styles 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 } + } + + /// The document language. + var lang: String { "en" } + + /// The document title. + 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; + } + } + """ +} diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift new file mode 100644 index 0000000..d55429b --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -0,0 +1,63 @@ +import Elementary + +/// The website's landing page. +struct IndexPage: HTMLDocument, Sendable { + + // MARK: Document + + /// The page's content. + var body: some HTML { + p { "Hello world! This is HTML5 Boilerplate." } + script(.src("js/app.js")) {} + } + + /// The metadata, stylesheet, icon, and manifest links placed in the document head. + var head: some HTML { + meta(.charset(.utf8)) + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + link( + .rel(.stylesheet), + .href("css/style.css") + ) + link( + .rel(.icon), + .href("/favicon.ico"), + .custom( + name: "sizes", + value: "any" + ) + ) + link( + .rel(.icon), + .href("/icon.svg"), + .custom( + name: "type", + value: "image/svg+xml" + ) + ) + link( + .rel("apple-touch-icon"), + .href("icon.png") + ) + link( + .rel("manifest"), + .href("site.webmanifest") + ) + meta( + .name("theme-color"), + .content("#fafafa") + ) + } + + /// The document language. + var lang: String { + "en" + } + + /// The document title. + var title: String { "" } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift new file mode 100644 index 0000000..0cd1ab6 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -0,0 +1,71 @@ +import Hummingbird +import HummingbirdElementary + +/// Serves the website's root routes. +/// +/// 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(RootController().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 RootController : Sendable{ + + // MARK: Initializers + + /// Creates a root controller. + public init() {} + + // MARK: Properties + + /// The routes served by the controller. + /// + /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage``. + public var routes: RouteCollection { + let routes = RouteCollection(context: Context.self) + + routes.get( + .Root.index, + use: index + ) + + return routes + } + +} + +// 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``. + @Sendable + func index( + request: Request, + context: some RequestContext + ) -> HTMLResponse { + .init { + IndexPage() + } + } + +} + +// MARK: - Constants + +extension RouterPath { + /// A namespace for the ``RootController`` route paths. + enum Root { + /// The path of the landing page. + static let index: RouterPath = "/" + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift index 58973bf..326e429 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift @@ -3,16 +3,7 @@ import Configuration extension ConfigValue { /// A namespace for the HTTP server's default configuration values. public enum HTTP { - /// The default host the server binds to. - public static let host: ConfigValue = .init(stringLiteral: "127.0.0.1") - /// The default port the server listens on. - public static let port: ConfigValue = .init(stringLiteral: "0") /// The default server name. public static let serverName: ConfigValue = .init(stringLiteral: .Server.name) } - /// A namespace for the logging default configuration values. - enum Log { - /// The default minimum log level. - public static let level: ConfigValue = .init(stringLiteral: "trace") - } } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift index d82fbfe..6032f9f 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift +++ b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift @@ -1,46 +1,19 @@ -import Foundation +import Elementary import Hummingbird -import NIOCore +import HummingbirdElementary /// 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 preloaded -/// error page and a `404 Not Found` status. +/// that bubbles up when no file exists for the requested path and responds with the rendered +/// ``ErrorPage`` and a `404 Not Found` status. public struct NotFoundMiddleware { - - // MARK: Properties - - /// The body of the error page served on a not-found response. - private let page: ByteBuffer - + // MARK: Initializers - - /// Creates a middleware that serves the error page (`404.html`) from the static files folder. - /// - /// The page is read once, at construction. A minimal fallback body is used when the file is - /// missing. - /// - Parameter staticFilesPath: the folder, relative to the working directory, the static files are served from. - public init( - _ staticFilesPath: String - ) { - let path = StaticFile.errorHTML.path(relativeTo: staticFilesPath) - - if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { - self.init(page: .init(bytes: data)) - } else { - self.init(page: .init(string: "404 Not Found")) - } - } - - /// Creates a middleware that serves the given error page on a not-found response. - /// - Parameter page: the body of the error page. - init( - page: ByteBuffer - ) { - self.page = page - } - + + /// Creates a not-found middleware. + public init() {} + } // MARK: - RouterMiddleware @@ -49,6 +22,16 @@ extension NotFoundMiddleware: RouterMiddleware { // MARK: Functions + /// Passes the request down the chain, rendering the error page if it results in a not-found + /// response. + /// + /// Any error other than `.notFound` is rethrown unchanged. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response, or the rendered ``ErrorPage`` with a `404 Not Found` status. + /// - Throws: any non-not-found error thrown downstream. public func handle( _ request: Request, context: Context, @@ -57,7 +40,6 @@ extension NotFoundMiddleware: RouterMiddleware { do { return try await next(request, context) } catch let error { - // Only intercept "not found"; let every other error propagate. guard let responseError = error as? any HTTPResponseError, responseError.status == .notFound @@ -65,14 +47,14 @@ extension NotFoundMiddleware: RouterMiddleware { throw error } - var headers = HTTPFields() - - headers[.contentType] = StaticFile.errorHTML.contentType - - return Response( - status: .notFound, - headers: headers, - body: .init(byteBuffer: page) + return HTMLResponse( + status: .notFound + ) { + ErrorPage() + } + .response( + from: request, + context: context ) } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index ec7dfb1..fd64115 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -27,21 +27,16 @@ struct AppTests { @Test func `landing page to be served at root`() async throws { - let file: StaticFile = .indexHTML - let app = try await application( - reader: reader( - staticFilesPath: staticFilesPath - ) - ) - try await app.test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in + let body = String(buffer: response.body) + #expect(response.status == .ok) - #expect(response.headers[.contentType] == file.contentType) - #expect(response.body == data(of: file)) + #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(body.contains("Hello world!")) } } } @@ -50,12 +45,6 @@ struct AppTests { func `static files to be served`( staticFile file: StaticFile ) async throws { - let app = try await application( - reader: reader( - staticFilesPath: staticFilesPath - ) - ) - try await app.test(.router) { client in try await client.execute( uri: "/\(file.relativePath)", @@ -69,21 +58,16 @@ struct AppTests { @Test func `error page to be served when not found`() async throws { - let file: StaticFile = .errorHTML - let app = try await application( - reader: reader( - staticFilesPath: staticFilesPath - ) - ) - try await app.test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in + let body = String(buffer: response.body) + #expect(response.status == .notFound) - #expect(response.headers[.contentType] == file.contentType) - #expect(response.body == data(of: file)) + #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(body.contains("Page Not Found")) } } } @@ -94,28 +78,28 @@ struct AppTests { private extension AppTests { - // MARK: Methods + // MARK: Computed - func data( - of file: StaticFile - ) -> ByteBuffer { - let url = URL(fileURLWithPath: file.path(relativeTo: staticFilesPath)) - - guard let data = try? Data(contentsOf: url) else { - return ByteBuffer() + var app: some ApplicationProtocol { + get async { + await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) } - - return ByteBuffer(bytes: data) } + // MARK: Methods + func reader( staticFilesPath: String ) -> ConfigReader { ConfigReader(providers: [ InMemoryProvider(values: [ - .HTTP.host: .HTTP.host, - .HTTP.port: .HTTP.port, - .Log.level: .Log.level, + .HTTP.host: "127.0.0.1", + .HTTP.port: "0", + .Log.level: "trace", .Path.staticFiles: .init(stringLiteral: staticFilesPath), ]) ]) diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 1ac3872..ba7b26d 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -82,7 +82,7 @@ struct StaticFileTests { @Test func `all cases`() { - #expect(File.allCases.count == 9) + #expect(File.allCases.count == 7) } } @@ -95,44 +95,36 @@ private extension StaticFileTests { static let contentTypes: [String] = [ "text/javascript", - "text/html", "image/vnd.microsoft.icon", "image/png", "image/svg+xml", - "text/html", "text/plain", "application/manifest+json", "text/css" ] static let fileExtensions: [FileExtension] = [ .js, - .html, .ico, .png, .svg, - .html, .txt, .webmanifest, .css ] static let fileNames: [String] = [ "app", - "404", "favicon", "icon", "icon", - "index", "robots", "site", "style" ] static let relativePaths: [String] = [ "js/app.js", - "404.html", "favicon.ico", "icon.png", "icon.svg", - "index.html", "robots.txt", "site.webmanifest", "css/style.css" diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift new file mode 100644 index 0000000..45d74cb --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -0,0 +1,20 @@ +import Elementary +import Testing + +@testable import WebsiteCore + +@Suite("ErrorPage page") +struct ErrorPageTests { + + // MARK: Functional tests + + @Test + func `renders its markup`() { + let html = ErrorPage().render() + + #expect(html.contains("")) + #expect(html.contains("Page Not Found")) + #expect(html.contains("does not exist")) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift new file mode 100644 index 0000000..1e0233c --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -0,0 +1,21 @@ +import Elementary +import Testing + +@testable import WebsiteCore + +@Suite("IndexPage page") +struct IndexPageTests { + + // MARK: Functional tests + + @Test + func `renders its markup`() { + let html = IndexPage().render() + + #expect(html.contains("")) + #expect(html.contains("Hello world!")) + #expect(html.contains("css/style.css")) + #expect(html.contains("js/app.js")) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift new file mode 100644 index 0000000..daa9e79 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -0,0 +1,39 @@ +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import WebsiteCore + +@Suite("RootController controller") +struct RootControllerTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router() + + router.addRoutes(RootController().routes) + + return router + }()) + + // MARK: Functional tests + + @Test + func `serves the landing page at the root path`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(body.contains("Hello world!")) + } + } + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift new file mode 100644 index 0000000..b4646e3 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -0,0 +1,77 @@ +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import WebsiteCore + +@Suite("NotFoundMiddleware middleware") +struct NotFoundMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router() + + router.addMiddleware { + NotFoundMiddleware() + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get("boom") { _, _ -> String in + throw HTTPError(.badRequest) + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `renders the error page for an unmatched request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(body.contains("Page Not Found")) + } + } + } + + @Test + func `passes a matched response through untouched`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } + + @Test + func `rethrows a non-not-found error unchanged`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/boom", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .badRequest) + #expect(!body.contains("Page Not Found")) + } + } + } + +} -- 2.54.0 From 6c1a0f713b2c74e06a0391c18e1a3cdb153562aa Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 05:49:32 +0000 Subject: [PATCH 007/117] Static file caching for the Website service (#6) This PR contains the work done to add cache-control headers to static file responses by configuring the `FileMiddleware` middleware to tag served static files with *Cache-Control* directives, tuned per media type. To provide further details about the work done: * Added a cache control that sets per-type policies: text assets (CSS/JS) get public, max-age, must-revalidate; images get public, max-age; everything else gets a default public, max-age. * Files stay validated via `ETag/Last-Modified` header. * Made the max-age values configurable, with defaults, via new cache constants. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/6 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .swift-format | 2 +- Services/Website/Sources/App/App+build.swift | 50 +++++++++++++++++-- .../AbsoluteConfigKey+Constants.swift | 9 ++++ .../Extensions/ConfigKey+Constants.swift | 9 ++++ .../Public/Extensions/Int+Constants.swift | 11 ++++ Services/Website/Tests/App/AppTests.swift | 19 ++++++- 6 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift diff --git a/.swift-format b/.swift-format index f015b36..fbd0fa3 100644 --- a/.swift-format +++ b/.swift-format @@ -39,7 +39,7 @@ "NeverForceUnwrap": true, "NeverUseForceTry": true, "NeverUseImplicitlyUnwrappedOptionals": true, - "NoAccessLevelOnExtensionDeclaration": true, + "NoAccessLevelOnExtensionDeclaration": false, "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 2358bb5..d79bf0f 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -12,6 +12,20 @@ import WebsiteCore func application( reader: ConfigReader ) async -> some ApplicationProtocol { + let cacheControl = cacheControl( + textMaxAge: reader.int( + forKey: .Cache.maxAgeText, + default: .Cache.maxAgeText + ), + imageMaxAge: reader.int( + forKey: .Cache.maxAgeImage, + default: .Cache.maxAgeImage + ), + defaultMaxAge: reader.int( + forKey: .Cache.maxAgeDefault, + default: .Cache.maxAgeDefault + ) + ) let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -29,6 +43,7 @@ func application( return Application( router: router( staticFilesPath: staticFilesPath, + cacheControl: cacheControl, logLevel: logLevel ), configuration: ApplicationConfiguration( @@ -46,6 +61,29 @@ func application( // Request context used by application private typealias AppRequestContext = BasicRequestContext +/// Builds the cache-control policy applied to the served static files. +/// +/// Static files are public and validated by `FileMiddleware` through their `ETag` and +/// `Last-Modified` headers, so each media type is given a `max-age` after which the browser +/// revalidates. Text-based assets (CSS, JavaScript) are additionally marked `must-revalidate` +/// since they change between deployments while keeping their filenames. +/// - Parameters: +/// - textMaxAge: the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text). +/// - imageMaxAge: the max-age, in seconds, applied to image static files (ICO, PNG, SVG). +/// - defaultMaxAge: the max-age, in seconds, applied to all other static files (e.g. the web manifest). +/// - Returns: the configured cache-control policy. +private func cacheControl( + textMaxAge: Int, + imageMaxAge: Int, + defaultMaxAge: Int +) -> CacheControl { + .init([ + (.text, [.public, .maxAge(textMaxAge), .mustRevalidate]), + (.image, [.public, .maxAge(imageMaxAge)]), + (.init(type: .any), [.public, .maxAge(defaultMaxAge)]), + ]) +} + /// Builds the application's logger. /// - Parameters: /// - serverName: the label applied to the logger. @@ -65,14 +103,17 @@ private func logger( /// Builds the application's router. /// /// Registers the request-logging middleware, the not-found middleware that serves the error -/// page, and the static file middleware that serves the contents of `staticFilesPath`, then -/// adds the `RootController` routes that render the landing page. +/// 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. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - cacheControl: the cache-control directives applied to the served static files. /// - logLevel: the level the request-logging middleware logs at. /// - Returns: the configured router. private func router( staticFilesPath: String, + cacheControl: CacheControl, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) @@ -80,7 +121,10 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) NotFoundMiddleware() - FileMiddleware(staticFilesPath) + FileMiddleware( + staticFilesPath, + cacheControl: cacheControl + ) } router.addRoutes(RootController().routes) diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 94a8cbf..93dec9c 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension AbsoluteConfigKey { + /// A namespace for the static files cache configuration keys, as absolute keys. + public enum Cache { + /// The absolute configuration key for the max-age, in seconds, applied to text-based static files. + public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText) + /// The absolute configuration key for the max-age, in seconds, applied to image static files. + public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage) + /// The absolute configuration key for the max-age, in seconds, applied to all other static files. + public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault) + } /// A namespace for the HTTP server configuration keys, as absolute keys. public enum HTTP { /// The absolute configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 55c6f7d..225db80 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension ConfigKey { + /// A namespace for the static files cache configuration keys. + public enum Cache { + /// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text). + public static let maxAgeText: ConfigKey = "cache.maxAge.text" + /// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG). + public static let maxAgeImage: ConfigKey = "cache.maxAge.image" + /// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest). + public static let maxAgeDefault: ConfigKey = "cache.maxAge.default" + } /// A namespace for the HTTP server configuration keys. public enum HTTP { /// The configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift new file mode 100644 index 0000000..489fa2a --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -0,0 +1,11 @@ +extension Int { + /// A namespace for the cache's default configuration values. + public enum Cache { + /// The default max-age, in seconds, applied to text-based static files (1 hour). + public static let maxAgeText = 3_600 + /// The default max-age, in seconds, applied to image static files (1 week). + public static let maxAgeImage = 604_800 + /// The default max-age, in seconds, applied to all other static files (1 day). + public static let maxAgeDefault = 86_400 + } +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index fd64115..15d0765 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -13,6 +13,12 @@ import Testing struct AppTests { // MARK: Constants + + private let textExtensions: [StaticFile.Extension] = [ + .css, + .js, + .txt + ] // Absolute path to the package's "Resources/Static" folder, derived from this // file's location so the static files resolve regardless of the working directory. @@ -35,7 +41,7 @@ struct AppTests { let body = String(buffer: response.body) #expect(response.status == .ok) - #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Hello world!")) } } @@ -52,6 +58,15 @@ struct AppTests { ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == file.contentType) + + let cacheControl = try #require(response.headers[.cacheControl]) + + #expect(cacheControl.contains("public") == true) + #expect(cacheControl.contains("max-age=") == true) + + if textExtensions.contains(file.fileExtension) { + #expect(cacheControl.contains("must-revalidate") == true) + } } } } @@ -66,7 +81,7 @@ struct AppTests { let body = String(buffer: response.body) #expect(response.status == .notFound) - #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Page Not Found")) } } -- 2.54.0 From 7c18cd9ec0c3c41df4223c5e6f3813213c8a4673 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 06:15:05 +0000 Subject: [PATCH 008/117] Response compression for the Website service (#7) This PR contains the work done to add response compression to the Website service by registering the `ResponseCompressionMiddleware` middleware so responses are compressed when the client advertises support and the body exceeds a minimum size. To provide further details about the work done: * Added the **HummingbirdCompression** package dependency. * Integrated the `ResponseCompressionMiddleware` middleware into the router, ahead of the not-found and static file middleware. * Made the `minimum-response-size-to-compress` threshold configurable, with a default. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/7 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Package.swift | 8 ++++++ Services/Website/Sources/App/App+build.swift | 24 ++++++++++++----- .../AbsoluteConfigKey+Constants.swift | 5 ++++ .../Extensions/ConfigKey+Constants.swift | 5 ++++ .../Public/Extensions/Int+Constants.swift | 5 ++++ Services/Website/Tests/App/AppTests.swift | 27 +++++++++++++++++++ 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 8b81b82..937f6e0 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -31,6 +31,10 @@ 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" + ), .package( url: "https://github.com/apple/swift-configuration.git", from: "1.0.0", @@ -53,6 +57,10 @@ let package = Package( name: "Hummingbird", package: "hummingbird" ), + .product( + name: "HummingbirdCompression", + package: "hummingbird-compression" + ), ], path: "Sources/App" ), diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index d79bf0f..67f9bf4 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -1,12 +1,13 @@ import Configuration import Hummingbird +import HummingbirdCompression import Logging import WebsiteCore /// Builds the website application. /// -/// Reads the log level, server name, and static files location from the configuration, -/// then assembles the router, server configuration, and logger. +/// Reads the log level, server name, static files location, and minimum response size to +/// compress from the configuration, then assembles the router, server configuration, and logger. /// - Parameter reader: the configuration reader the values are read from. /// - Returns: the configured application, ready to run as a service. func application( @@ -26,6 +27,10 @@ func application( default: .Cache.maxAgeDefault ) ) + let compressionMinResponseSize = reader.int( + forKey: .Compression.minResponseSize, + default: .Compression.minResponseSize + ) let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -44,6 +49,7 @@ func application( router: router( staticFilesPath: staticFilesPath, cacheControl: cacheControl, + compressionMinResponseSize: compressionMinResponseSize, logLevel: logLevel ), configuration: ApplicationConfiguration( @@ -102,24 +108,30 @@ private func logger( /// Builds the application's router. /// -/// Registers the request-logging middleware, 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. +/// Registers the request-logging middleware, the response-compression middleware that compresses +/// 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. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - cacheControl: the cache-control directives applied to the served static files. +/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. /// - logLevel: the level the request-logging middleware logs at. /// - Returns: the configured router. private func router( staticFilesPath: String, cacheControl: CacheControl, + compressionMinResponseSize: Int, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { LogRequestsMiddleware(logLevel) + ResponseCompressionMiddleware( + minimumResponseSizeToCompress: compressionMinResponseSize + ) NotFoundMiddleware() FileMiddleware( staticFilesPath, diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 93dec9c..f1ab24d 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -10,6 +10,11 @@ extension AbsoluteConfigKey { /// The absolute configuration key for the max-age, in seconds, applied to all other static files. public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault) } + /// A namespace for the response compression configuration keys, as absolute keys. + public enum Compression { + /// The absolute configuration key for the minimum response body size, in bytes, before compression is applied. + public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize) + } /// A namespace for the HTTP server configuration keys, as absolute keys. public enum HTTP { /// The absolute configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 225db80..4a34574 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -10,6 +10,11 @@ extension ConfigKey { /// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest). public static let maxAgeDefault: ConfigKey = "cache.maxAge.default" } + /// A namespace for the response compression configuration keys. + public enum Compression { + /// The configuration key for the minimum response body size, in bytes, before compression is applied. + public static let minResponseSize: ConfigKey = "compression.minimumResponseSize" + } /// A namespace for the HTTP server configuration keys. public enum HTTP { /// The configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index 489fa2a..d6976ef 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -8,4 +8,9 @@ extension Int { /// The default max-age, in seconds, applied to all other static files (1 day). public static let maxAgeDefault = 86_400 } + /// A namespace for the response compression's default configuration values. + public enum Compression { + /// The default minimum response body size, in bytes, before compression is applied (1 KB). + public static let minResponseSize = 1_024 + } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 15d0765..13cce8c 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -71,6 +71,33 @@ struct AppTests { } } + @Test + func `response to be compressed when the client supports it`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.acceptEncoding: "gzip"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentEncoding] == "gzip") + } + } + } + + @Test + func `response to not be compressed when the client does not support it`() 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[.contentEncoding] == nil) + } + } + } + @Test func `error page to be served when not found`() async throws { try await app.test(.router) { client in -- 2.54.0 From 6b6389cb0fa0e03e84e5cc8d820592d35ceb8a34 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 11:35:54 +0000 Subject: [PATCH 009/117] Security header setup for the Website service (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to add a `SecurityHeadersMiddleware` middleware that stamps hardened security-related HTTP headers onto every response. To provide further details about the work: * Implemented the `SecurityHeadersMiddleware` middleware, which precomputes headers once from a `Configuration` object and applies them to every response: * _Content-Security-Policy_, * _X-Content-Type-Options_, * _X-Frame-Options_, * _Referrer-Policy_, * _Permissions-Policy_, * _Strict-Transport-Security_ (optional). * Integrated this middleware into the router (near the top of the chain), reading each value from configuration with hardened defaults. * The _Strict-Transport-Security_ has no default value — omitted unless explicitly set, so it stays off in plain-HTTP during development and on only behind TLS. * Added security-header constants keys and values. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/8 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Sources/App/App+build.swift | 58 ++++++- .../Extensions/HTTPFieldName+Constants.swift | 10 ++ .../AbsoluteConfigKey+Constants.swift | 15 ++ .../Extensions/ConfigKey+Constants.swift | 15 ++ .../Public/Extensions/String+Constants.swift | 20 +++ .../SecurityHeadersMiddleware.swift | 146 ++++++++++++++++++ Services/Website/Tests/App/AppTests.swift | 129 ++++++++++++---- .../SecurityHeadersMiddlewareTests.swift | 134 ++++++++++++++++ Services/Website/docker-compose.yml | 1 + 9 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 67f9bf4..6514a8e 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -6,8 +6,8 @@ import WebsiteCore /// Builds the website application. /// -/// Reads the log level, server name, static files location, and minimum response size to -/// compress from the configuration, then assembles the router, server configuration, and logger. +/// Reads the log level, server name, static files location, minimum response size to compress, and +/// security headers from the configuration, then assembles the router, server configuration, and logger. /// - Parameter reader: the configuration reader the values are read from. /// - Returns: the configured application, ready to run as a service. func application( @@ -44,12 +44,16 @@ func application( forKey: .Path.staticFiles, default: .Path.staticResources ) + let securityHeaders = securityHeaders( + reader: reader + ) return Application( router: router( staticFilesPath: staticFilesPath, cacheControl: cacheControl, compressionMinResponseSize: compressionMinResponseSize, + securityHeaders: securityHeaders, logLevel: logLevel ), configuration: ApplicationConfiguration( @@ -90,6 +94,44 @@ private func cacheControl( ]) } +/// Builds the security-headers configuration applied to every response. +/// +/// Each header value falls back to the hardened default in `String.Security` when the matching +/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional +/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and +/// is enabled only behind TLS in production. +/// - Parameter reader: the configuration reader the header values are read from. +/// - Returns: the configured security-headers configuration. +private func securityHeaders( + reader: ConfigReader +) -> SecurityHeadersMiddleware.Configuration { + .init( + contentSecurityPolicy: reader.string( + forKey: .Security.contentSecurityPolicy, + default: .Security.contentSecurityPolicy + ), + contentTypeOptions: reader.string( + forKey: .Security.contentTypeOptions, + default: .Security.contentTypeOptions + ), + frameOptions: reader.string( + forKey: .Security.frameOptions, + default: .Security.frameOptions + ), + referrerPolicy: reader.string( + forKey: .Security.referrerPolicy, + default: .Security.referrerPolicy + ), + permissionsPolicy: reader.string( + forKey: .Security.permissionsPolicy, + default: .Security.permissionsPolicy + ), + strictTransportSecurity: reader.string( + forKey: .Security.strictTransportSecurity + ) + ) +} + /// Builds the application's logger. /// - Parameters: /// - serverName: the label applied to the logger. @@ -108,27 +150,37 @@ private func logger( /// Builds the application's router. /// -/// Registers the request-logging middleware, the response-compression middleware that compresses +/// Registers the request-logging middleware, the security-headers middleware that stamps the given +/// `securityHeaders` onto every response, the response-compression middleware that compresses /// 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. +/// +/// 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 +/// served static files. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - cacheControl: the cache-control directives applied to the served static files. /// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. +/// - securityHeaders: the security headers applied to every response. /// - logLevel: the level the request-logging middleware logs at. /// - Returns: the configured router. private func router( staticFilesPath: String, cacheControl: CacheControl, compressionMinResponseSize: Int, + securityHeaders: SecurityHeadersMiddleware.Configuration, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { LogRequestsMiddleware(logLevel) + SecurityHeadersMiddleware( + configuration: securityHeaders + ) ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift b/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift new file mode 100644 index 0000000..4ebf3bb --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift @@ -0,0 +1,10 @@ +import HTTPTypes + +extension HTTPField.Name { + /// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`). + static let permissionsPolicy = Self("Permissions-Policy")! + /// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`). + static let referrerPolicy = Self("Referrer-Policy")! + /// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`). + static let frameOptions = Self("X-Frame-Options")! +} diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index f1ab24d..06afdd4 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -34,4 +34,19 @@ extension AbsoluteConfigKey { /// The absolute configuration key for the directory the static files are served from. public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles) } + /// A namespace for the security headers configuration keys, as absolute keys. + public enum Security { + /// The absolute configuration key for the `Content-Security-Policy` header value. + public static let contentSecurityPolicy: AbsoluteConfigKey = .init(.Security.contentSecurityPolicy) + /// The absolute configuration key for the `X-Content-Type-Options` header value. + public static let contentTypeOptions: AbsoluteConfigKey = .init(.Security.contentTypeOptions) + /// The absolute configuration key for the `X-Frame-Options` header value. + public static let frameOptions: AbsoluteConfigKey = .init(.Security.frameOptions) + /// The absolute configuration key for the `Referrer-Policy` header value. + public static let referrerPolicy: AbsoluteConfigKey = .init(.Security.referrerPolicy) + /// The absolute configuration key for the `Permissions-Policy` header value. + public static let permissionsPolicy: AbsoluteConfigKey = .init(.Security.permissionsPolicy) + /// The absolute configuration key for the `Strict-Transport-Security` header value. + public static let strictTransportSecurity: AbsoluteConfigKey = .init(.Security.strictTransportSecurity) + } } diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 4a34574..5d27088 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -34,4 +34,19 @@ extension ConfigKey { /// The configuration key for the directory the static files are served from. public static let staticFiles: ConfigKey = "path.staticFiles" } + /// A namespace for the security headers configuration keys. + public enum Security { + /// The configuration key for the `Content-Security-Policy` header value. + public static let contentSecurityPolicy: ConfigKey = "security.contentSecurityPolicy" + /// The configuration key for the `X-Content-Type-Options` header value. + public static let contentTypeOptions: ConfigKey = "security.contentTypeOptions" + /// The configuration key for the `X-Frame-Options` header value. + public static let frameOptions: ConfigKey = "security.frameOptions" + /// The configuration key for the `Referrer-Policy` header value. + public static let referrerPolicy: ConfigKey = "security.referrerPolicy" + /// The configuration key for the `Permissions-Policy` header value. + public static let permissionsPolicy: ConfigKey = "security.permissionsPolicy" + /// The configuration key for the `Strict-Transport-Security` header value (omitted when unset). + public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity" + } } diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 22a1e0f..63639d9 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -4,6 +4,26 @@ extension String { /// The directory, relative to the working directory, that the website's static files are served from. public static let staticResources = "Resources/Static" } + /// A namespace for the security headers' default configuration values. + /// + /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is + /// "sticky" in browsers, so it stays off unless explicitly configured in production. + public enum Security { + /// The default `Content-Security-Policy`. + /// + /// Restricts every resource to the site's own origin. `style-src` additionally allows + /// `'unsafe-inline'` because ``ErrorPage`` ships an inline ` diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest index 222ae16..e5e517f 100644 --- a/Services/Website/Resources/Static/site.webmanifest +++ b/Services/Website/Resources/Static/site.webmanifest @@ -2,9 +2,13 @@ "short_name": "", "name": "", "icons": [{ - "src": "icon.png", + "src": "icon-192.png", "type": "image/png", "sizes": "192x192" + }, { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" }], "start_url": "/?utm_source=homescreen", "background_color": "#fafafa", diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index aed99f7..69afb22 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -10,8 +10,12 @@ enum StaticFile: CaseIterable, Sendable { case error /// The `favicon.ico` icon. case favicon - /// The `icon.png` and `icon.svg` icons. + /// The `icon.svg` icon. case icon + /// The `icon-192.png` icon for the web manifest. + case icon192 + /// The `icon-512.png` icon for the web manifest. + case icon512 /// The `css/index.css` stylesheet and `js/index.js` script for the landing page. case index /// The `robots.txt` crawler directives. @@ -57,12 +61,14 @@ extension StaticFile { /// The file extensions the file is available with. var fileExtensions: [Extension] { switch self { - case .appleTouchIcon: [.png] + case .appleTouchIcon, + .icon192, + .icon512: [.png] case .error, .index, .shared: [.css, .js] case .favicon: [.ico] - case .icon: [.png, .svg] + case .icon: [.svg] case .robots: [.txt] case .site: [.webmanifest] case .sitemap: [.xml] @@ -76,6 +82,8 @@ extension StaticFile { case .error: "error" case .favicon: "favicon" case .icon: "icon" + case .icon192: "icon-192" + case .icon512: "icon-512" case .index: "index" case .robots: "robots" case .shared: "shared" diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 7f8bf19..6a9b6e2 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -74,6 +74,14 @@ struct IndexPage: HTMLDocument, Sendable { .rel("manifest"), .href(StaticFile.site.urlPath(for: .webmanifest)) ) + meta( + .name("theme-color"), + .content("#0c0710"), + .custom( + name: "media", + value: "(prefers-color-scheme: dark)" + ) + ) meta( .name("theme-color"), .content("#fafafa") diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 4cb579f..24bde11 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -114,7 +114,7 @@ struct StaticFileTests { @Test func `all cases`() { - #expect(File.allCases.count == 9) + #expect(File.allCases.count == 11) } } @@ -159,7 +159,9 @@ private extension StaticFileTests { [.png], [.css, .js], [.ico], - [.png, .svg], + [.svg], + [.png], + [.png], [.css, .js], [.txt], [.css, .js], @@ -171,6 +173,8 @@ private extension StaticFileTests { "error", "favicon", "icon", + "icon-192", + "icon-512", "index", "robots", "shared", @@ -181,7 +185,9 @@ private extension StaticFileTests { ["apple-touch-icon.png"], ["css/error.css", "js/error.js"], ["favicon.ico"], - ["icon.png", "icon.svg"], + ["icon.svg"], + ["icon-192.png"], + ["icon-512.png"], ["css/index.css", "js/index.js"], ["robots.txt"], ["css/shared.css", "js/shared.js"], diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index a754d77..fc308d2 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -23,6 +23,7 @@ struct IndexPageTests { #expect(html.contains("/icon.svg")) #expect(html.contains("/apple-touch-icon.png")) #expect(html.contains("/site.webmanifest")) + #expect(html.contains(#"media="(prefers-color-scheme: dark)""#)) #expect(html.contains("Hello world!")) #expect(html.contains("/js/shared.js")) #expect(html.contains("/js/index.js")) -- 2.54.0 From d0058dc636fa2656e45f4eebc52e4d9b52215846 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 08:56:35 +0000 Subject: [PATCH 024/117] Shared HTML template for the Website service (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to define a `Page` protocol that extracts the HTML scaffolding that `IndexPage` and `ErrorPage` pages duplicated, so every page of the website declares only what makes it unique — its content, title, and assets — while the document structure lives in one place. Also cleans up imports across the workspace. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/22 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Cases/Public/Methods/ProbeTests.swift | 1 - .../RouterMethods+RouteCollectionsTests.swift | 1 + Services/Website/Sources/App/App.swift | 1 - .../Sources/App/Extensions/App+Build.swift | 1 + .../Library/Internal/Pages/ErrorPage.swift | 40 +++---- .../Library/Internal/Pages/IndexPage.swift | 77 +++---------- .../Library/Internal/Protocols/Page.swift | 107 ++++++++++++++++++ .../Public/Controllers/RootController.swift | 4 +- Services/Website/Tests/App/AppTests.swift | 1 - .../Enumerations/StaticFileTests.swift | 1 - 10 files changed, 139 insertions(+), 95 deletions(-) create mode 100644 Services/Website/Sources/Library/Internal/Protocols/Page.swift diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift index 92ff75e..4458f0e 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -1,7 +1,6 @@ import FluentKit import HummingbirdFluent import Logging -import NIOCore import Testing @testable import Persistence diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 1808834..2f6edc5 100644 --- a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -1,5 +1,6 @@ import Hummingbird import HummingbirdTesting +import NIOCore import Testing @testable import Web diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index ee99ab9..679beff 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -1,6 +1,5 @@ import Configuration import Hummingbird -import Logging /// The entry point of the website executable. /// diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 2bb0d52..1096740 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -3,6 +3,7 @@ import Hummingbird import HummingbirdCompression import Logging import Persistence +import Web import WebsiteLibrary /// Builds the website application. diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 79a1f53..3f332fb 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -3,12 +3,12 @@ import Foundation import Localization /// The HTML page rendered for a not-found response, with its text localized to a given locale. -struct ErrorPage: HTMLDocument, Sendable { +struct ErrorPage { // MARK: Properties /// The locale the page content is localized to. - private let locale: Locale + let locale: Locale /// Resolves the page's text from the bundled String Catalog for the page's ``locale``. private let localize: Localize @@ -23,45 +23,31 @@ struct ErrorPage: HTMLDocument, Sendable { self.locale = locale self.localize = .init(bundle: .module) } +} - // MARK: Document +// MARK: Page - /// The page's content: a localized heading and explanatory message, followed by the error and shared scripts. - var body: some HTML { +extension ErrorPage: Page { + + // MARK: Properties + + var content: some HTML { h1 { localize("error.heading", locale: locale) } p { localize("error.message", locale: locale) } - script(.src(StaticFile.error.urlPath(for: .js))) {} - script(.src(StaticFile.shared.urlPath(for: .js))) {} } - /// The metadata and stylesheet links placed in the document head. - var head: some HTML { - meta(.charset(.utf8)) - meta( - .name(.viewport), - .content("width=device-width, initial-scale=1") - ) - link( - .rel(.stylesheet), - .href(StaticFile.shared.urlPath(for: .css)) - ) - link( - .rel(.stylesheet), - .href(StaticFile.error.urlPath(for: .css)) - ) + var scripts: [StaticFile] { + [.error, .shared] } - /// The document language, derived from the page's locale and falling back to the default language. - var lang: String { - locale.language.languageCode?.identifier - ?? LanguageList(bundle: .module).default + var stylesheets: [StaticFile] { + [.shared, .error] } - /// The localized document title. var title: String { localize("error.title", locale: locale) } diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 6a9b6e2..aabef0b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -3,12 +3,12 @@ import Foundation import Localization /// The website's landing page, with its text localized to a given locale. -struct IndexPage: HTMLDocument, Sendable { +struct IndexPage { // MARK: Properties /// The locale the page content is localized to. - private let locale: Locale + let locale: Locale /// Resolves the page's text from the bundled String Catalog for the page's ``locale``. private let localize: Localize @@ -24,77 +24,28 @@ struct IndexPage: HTMLDocument, Sendable { self.localize = .init(bundle: .module) } - // MARK: Document +} - /// The page's content: a localized greeting followed by the shared and index scripts. - var body: some HTML { +// MARK: - Page + +extension IndexPage: Page { + + // MARK: Properties + + var content: some HTML { p { localize("index.greeting", locale: locale) } - script(.src(StaticFile.index.urlPath(for: .js))) {} - script(.src(StaticFile.shared.urlPath(for: .js))) {} } - /// The metadata, stylesheet, icon, and manifest links placed in the document head. - var head: some HTML { - meta(.charset(.utf8)) - meta( - .name(.viewport), - .content("width=device-width, initial-scale=1") - ) - link( - .rel(.stylesheet), - .href(StaticFile.shared.urlPath(for: .css)) - ) - link( - .rel(.stylesheet), - .href(StaticFile.index.urlPath(for: .css)) - ) - link( - .rel(.icon), - .href(StaticFile.favicon.urlPath(for: .ico)), - .custom( - name: "sizes", - value: "any" - ) - ) - link( - .rel(.icon), - .href(StaticFile.icon.urlPath(for: .svg)), - .custom( - name: "type", - value: "image/svg+xml" - ) - ) - link( - .rel("apple-touch-icon"), - .href(StaticFile.appleTouchIcon.urlPath(for: .png)) - ) - link( - .rel("manifest"), - .href(StaticFile.site.urlPath(for: .webmanifest)) - ) - meta( - .name("theme-color"), - .content("#0c0710"), - .custom( - name: "media", - value: "(prefers-color-scheme: dark)" - ) - ) - meta( - .name("theme-color"), - .content("#fafafa") - ) + var scripts: [StaticFile] { + [.index, .shared] } - /// The document language, derived from the page's locale and falling back to the default language. - var lang: String { - locale.language.languageCode?.identifier - ?? LanguageList(bundle: .module).default + var stylesheets: [StaticFile] { + [.shared, .index] } - /// The localized document title. var title: String { localize("index.title", locale: locale) } diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift new file mode 100644 index 0000000..e05eb45 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Protocols/Page.swift @@ -0,0 +1,107 @@ +import Elementary +import Foundation +import Localization + +/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content. +/// +/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the +/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the +/// document language derived from the locale. +protocol Page: HTMLDocument, Sendable { + + // MARK: Associated types + + /// The type of the page's markup. + associatedtype Content: HTML + + // MARK: Properties + + /// The page's markup, rendered before the ``scripts``. + @HTMLBuilder + var content: Content { get } + + /// The locale the page content is localized to. + var locale: Locale { get } + + /// The scripts loaded at the end of the document body, in order. + var scripts: [StaticFile] { get } + + /// The stylesheets linked in the document head, in order. + var stylesheets: [StaticFile] { get } + +} + +// MARK: - Implementations + +extension Page { + + // MARK: Computed + + /// The page ``content`` followed by its ``scripts``. + @HTMLBuilder + var body: some HTML { + content + for file in scripts { + script(.src(file.urlPath(for: .js))) {} + } + } + + /// The metadata, ``stylesheets``, icon, and manifest links placed in the document head. + @HTMLBuilder + var head: some HTML { + meta(.charset(.utf8)) + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath(for: .css)) + ) + } + link( + .rel(.icon), + .href(StaticFile.favicon.urlPath(for: .ico)), + .custom( + name: "sizes", + value: "any" + ) + ) + link( + .rel(.icon), + .href(StaticFile.icon.urlPath(for: .svg)), + .custom( + name: "type", + value: "image/svg+xml" + ) + ) + link( + .rel("apple-touch-icon"), + .href(StaticFile.appleTouchIcon.urlPath(for: .png)) + ) + link( + .rel("manifest"), + .href(StaticFile.site.urlPath(for: .webmanifest)) + ) + meta( + .name("theme-color"), + .content("#fafafa") + ) + meta( + .name("theme-color"), + .content("#0c0710"), + .custom( + name: "media", + value: "(prefers-color-scheme: dark)" + ) + ) + } + + /// The document language, derived from the page's locale and falling back to the default language. + var lang: String { + locale.language.languageCode?.identifier + ?? LanguageList(bundle: .module).default + } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index de32221..7a0dff0 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -25,7 +25,9 @@ public struct RootController { /// Creates a root controller. public init() { - self.responses = .init { IndexPage(locale: $0) } + self.responses = .init { + IndexPage(locale: $0) + } } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index e665763..23fe521 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -2,7 +2,6 @@ import Configuration import Foundation import Hummingbird import HummingbirdTesting -import Logging import NIOCore import Testing diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 24bde11..2ca9b20 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -1,4 +1,3 @@ -import Foundation import Testing @testable import WebsiteLibrary -- 2.54.0 From 242cb92bc55e5d20dff2ef911239e835213cac7f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 18:23:46 +0000 Subject: [PATCH 025/117] Few small fixes for the Website service (#23) This PR contains the work done to address small fixes. To provide further details: * HTML template * Removed the duplicate charset tag from the Page protocol's head property. * Router * Enabled auto-generated HEAD endpoints so every GET route gets a HEAD sibling. Uptime monitors and crawlers probing with HEAD now receive the page's status and headers instead of a 404. * Docker * Pinned the asset optimizer versionsvia build args so minified output is reproducible for a given Dockerfile commit. * Narrowed the build context copied into the release stage, only package manifests and Swift sources are copied. Static assets come from the separate assets stage after the binary is built. * svgo now minifies all SVGs recursively rather than just icon.svg, and the staging step creates Resources/Static explicitly instead of conditionally moving the unminified sources. * Added curl to the runtime image (needed for the container healthcheck) and .claude to .dockerignore. * Docker-compose * Added a `healthcheck` to the website service hitting GET /health (liveness only), so Compose reports process health without coupling container health to database reachability. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/23 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .dockerignore | 3 +- Services/Website/Dockerfile | 31 +++++++++++++------ Services/Website/README.md | 6 +++- .../Sources/App/Extensions/App+Build.swift | 7 ++++- .../Library/Internal/Protocols/Page.swift | 4 ++- Services/Website/Tests/App/AppTests.swift | 16 ++++++++++ Services/Website/docker-compose.yml | 6 ++++ 7 files changed, 59 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index a60105d..b84d7f9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,9 +13,10 @@ # Xcode project (not used by the Linux build) *.xcodeproj -# OS / editor cruft +# OS / editor / tooling cruft **/.DS_Store .vscode +.claude # Local environment overrides and secrets (Compose still reads these from the # host at runtime; ignoring them here only keeps them out of the image build). diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 6f3f1d4..98ac38a 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -3,9 +3,15 @@ # ================================ FROM node:22-alpine AS assets -# Install the minifiers in their own layer, so they are cached across asset changes -RUN apk add --no-cache oxipng \ - && npm install --global esbuild svgo +ARG ESBUILD_VERSION=0.28.1 +ARG OXIPNG_VERSION=9.1.5 +ARG SVGO_VERSION=4.0.2 + +# Install the minifiers in their own layer, so they are cached across asset changes. +# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do +# not break the build when the base image advances. +RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \ + && npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}" # Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress # the PNG images in place, keeping their names so the URL paths derived from the @@ -15,7 +21,7 @@ COPY ./Services/Website/Resources/Static . RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \ && esbuild --minify --allow-overwrite --outdir=js js/*.js \ && oxipng --opt max --strip safe *.png \ - && svgo icon.svg + && svgo --recursive --folder . # Export stage: `docker build --target assets-export --output ` writes the # minified static files to for local inspection. @@ -47,8 +53,13 @@ COPY ./Packages/Web/Package.swift ./Packages/Web/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve -# Copy entire repo into container -COPY . . +# Copy only the Swift inputs needed for a release build. Static assets are built +# in the assets stage and copied into staging after the binary is produced. +COPY ./Packages/Localization/Sources ./Packages/Localization/Sources +COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources +COPY ./Packages/Web/Sources ./Packages/Web/Sources +COPY ./Services/Website/Sources ./Services/Website/Sources +COPY ./Services/Website/Tests ./Services/Website/Tests # Build the application, with optimizations, with static linking, and using jemalloc RUN swift build --package-path ./Services/Website -c release \ @@ -68,10 +79,9 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; -# Copy the static files directory (served by FileMiddleware) if it exists -RUN [ -d /build/Services/Website/Resources ] && mv /build/Services/Website/Resources ./Resources || true - -# Overwrite the static files with the minified copies from the assets stage +# Create the static files directory (served by FileMiddleware) and fill it with +# the minified copies from the assets stage +RUN mkdir -p ./Resources/Static COPY --from=assets /static ./Resources/Static # Ensure that by default, neither the directory nor any of its contents are writable. @@ -89,6 +99,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ && apt-get -q install -y \ libjemalloc2 \ ca-certificates \ + curl \ tzdata \ # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. # libcurl4 \ diff --git a/Services/Website/README.md b/Services/Website/README.md index 7e410c7..ccaa62c 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -166,6 +166,8 @@ DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB ### Health checks `GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure. +`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability. + ## Testing ```sh make pkg-test @@ -201,13 +203,15 @@ docker compose -f docker-compose.yml up -d ``` ### Static assets -The image build optimizes the files under `Resources/Static` in its `assets` stage, in place: +The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit: - CSS and JS are minified with [esbuild](https://esbuild.github.io). - PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — the output is pixel-identical, only encoded smaller. - The SVG icon is minified with [svgo](https://github.com/svg/svgo). Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies. +The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache. + Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`: ```sh make ast-minify diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 1096740..45814b8 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -140,7 +140,12 @@ private func router( logLevel: Logger.Level, probe: Probe ) -> Router { - let router = Router(context: AppRequestContext.self) + // HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing + // with HEAD requests get the page's status and headers instead of a 404. + let router = Router( + context: AppRequestContext.self, + options: .autoGenerateHeadEndpoints + ) router.addMiddleware { LogRequestsMiddleware(logLevel) diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift index e05eb45..e8bc515 100644 --- a/Services/Website/Sources/Library/Internal/Protocols/Page.swift +++ b/Services/Website/Sources/Library/Internal/Protocols/Page.swift @@ -47,9 +47,11 @@ extension Page { } /// The metadata, ``stylesheets``, icon, and manifest links placed in the document head. + /// + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already + /// emits `` before this markup, and HTML5 allows only one. @HTMLBuilder var head: some HTML { - meta(.charset(.utf8)) meta( .name(.viewport), .content("width=device-width, initial-scale=1") diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 23fe521..1b45edb 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -48,6 +48,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( diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index 81c40d5..0aa0ffb 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -31,3 +31,9 @@ services: DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams} DATABASE_PASSWORD: ${DATABASE_PASSWORD:-} DATABASE_TLS: ${DATABASE_TLS:-require} + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s -- 2.54.0 From a8682753474556b802268aafbed2349491972072 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 22 Jul 2026 21:26:55 +0000 Subject: [PATCH 026/117] Renamed the Web package as Infrastructure (#24) Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/24 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcschemes/Infrastructure.xcscheme} | 12 ++++++------ Packages/{Web => Infrastructure}/Package.swift | 12 ++++++------ .../Public/Builders/RouteCollectionBuilder.swift | 0 .../RouterMethods+RouteCollections.swift | 0 .../Public/Protocols/RouterController.swift | 0 .../RouterMethods+RouteCollectionsTests.swift | 2 +- .../Tests/Utils/Controllers/StubController.swift | 2 +- Services/Website/Dockerfile | 4 ++-- Services/Website/Package.swift | 6 +++--- Services/Website/README.md | 2 +- .../Sources/App/Extensions/App+Build.swift | 2 +- .../Public/Controllers/HealthController.swift | 2 +- .../Public/Controllers/RootController.swift | 2 +- Services/Website/Tests/App/AppTests.swift | 16 ---------------- Services/Website/Tests/Website.xctestplan | 6 +++--- 15 files changed, 26 insertions(+), 42 deletions(-) rename Packages/{Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme => Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme} (88%) rename Packages/{Web => Infrastructure}/Package.swift (79%) rename Packages/{Web => Infrastructure}/Sources/Public/Builders/RouteCollectionBuilder.swift (100%) rename Packages/{Web => Infrastructure}/Sources/Public/Extensions/RouterMethods+RouteCollections.swift (100%) rename Packages/{Web => Infrastructure}/Sources/Public/Protocols/RouterController.swift (100%) rename Packages/{Web => Infrastructure}/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift (99%) rename Packages/{Web => Infrastructure}/Tests/Utils/Controllers/StubController.swift (96%) diff --git a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme similarity index 88% rename from Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme rename to Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme index 5212084..56914ab 100644 --- a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme +++ b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme @@ -15,8 +15,8 @@ buildForAnalyzing = "YES"> @@ -33,8 +33,8 @@ skipped = "NO"> @@ -61,8 +61,8 @@ diff --git a/Packages/Web/Package.swift b/Packages/Infrastructure/Package.swift similarity index 79% rename from Packages/Web/Package.swift rename to Packages/Infrastructure/Package.swift index c0c94e8..2f9227a 100644 --- a/Packages/Web/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -3,15 +3,15 @@ import PackageDescription let package = Package( - name: "Web", + name: "Infrastructure", platforms: [ .macOS(.v15), ], products: [ .library( - name: "Web", + name: "Infrastructure", targets: [ - "Web" + "Infrastructure" ] ), ], @@ -23,7 +23,7 @@ let package = Package( ], targets: [ .target( - name: "Web", + name: "Infrastructure", dependencies: [ .product( name: "Hummingbird", @@ -33,9 +33,9 @@ let package = Package( path: "Sources" ), .testTarget( - name: "WebTests", + name: "InfrastructureTests", dependencies: [ - .byName(name: "Web"), + .byName(name: "Infrastructure"), .product( name: "HummingbirdTesting", package: "hummingbird" diff --git a/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift similarity index 100% rename from Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift rename to Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift diff --git a/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift similarity index 100% rename from Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift rename to Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift diff --git a/Packages/Web/Sources/Public/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift similarity index 100% rename from Packages/Web/Sources/Public/Protocols/RouterController.swift rename to Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift similarity index 99% rename from Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 2f6edc5..0bae410 100644 --- a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -3,7 +3,7 @@ import HummingbirdTesting import NIOCore import Testing -@testable import Web +@testable import Infrastructure @Suite("addController method") struct RouterMethodsTests { diff --git a/Packages/Web/Tests/Utils/Controllers/StubController.swift b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift similarity index 96% rename from Packages/Web/Tests/Utils/Controllers/StubController.swift rename to Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift index 75b7586..48e2e24 100644 --- a/Packages/Web/Tests/Utils/Controllers/StubController.swift +++ b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift @@ -1,5 +1,5 @@ import Hummingbird -import Web +import Infrastructure /// A controller serving its path back as plain text, used to observe route registration. struct StubController { diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 98ac38a..f0fef04 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -49,7 +49,7 @@ WORKDIR /build # a relative path, so its manifest must be present for resolution to succeed. COPY ./Packages/Localization/Package.swift ./Packages/Localization/ COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/ -COPY ./Packages/Web/Package.swift ./Packages/Web/ +COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve @@ -57,7 +57,7 @@ RUN swift package --package-path ./Services/Website resolve # in the assets stage and copied into staging after the binary is produced. COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources -COPY ./Packages/Web/Sources ./Packages/Web/Sources +COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 39c5aad..72dd107 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -27,7 +27,7 @@ let package = Package( path: "../../Packages/Persistence" ), .package( - path: "../../Packages/Web" + path: "../../Packages/Infrastructure" ), .package( url: "https://github.com/elementary-swift/elementary.git", @@ -80,7 +80,7 @@ let package = Package( dependencies: [ .byName(name: "Localization"), .byName(name: "Persistence"), - .byName(name: "Web"), + .byName(name: "Infrastructure"), .product( name: "Configuration", package: "swift-configuration" @@ -120,7 +120,7 @@ let package = Package( name: "WebsiteLibraryTests", dependencies: [ .byName(name: "Persistence"), - .byName(name: "Web"), + .byName(name: "Infrastructure"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/README.md b/Services/Website/README.md index ccaa62c..8178dfc 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -26,7 +26,7 @@ Two SwiftPM targets: The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `Web` (`Packages/Web`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. +- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 45814b8..e7bd7bc 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -3,7 +3,7 @@ import Hummingbird import HummingbirdCompression import Logging import Persistence -import Web +import Infrastructure import WebsiteLibrary /// Builds the website application. diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index 55f732e..4597e27 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -1,7 +1,7 @@ import Hummingbird import NIOCore import Persistence -import Web +import Infrastructure /// Serves the website's health-check routes. /// diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 7a0dff0..f4995ed 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,5 +1,5 @@ import Hummingbird -import Web +import Infrastructure /// Serves the website's root routes. /// diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 1b45edb..23fe521 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -48,22 +48,6 @@ 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( diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index 0a36976..b82c471 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -48,9 +48,9 @@ }, { "target" : { - "containerPath" : "container:..\/..\/Packages\/Web", - "identifier" : "WebTests", - "name" : "WebTests" + "containerPath" : "container:..\/..\/Packages\/Infrastructure", + "identifier" : "InfrastructureTests", + "name" : "InfrastructureTests" } } ], -- 2.54.0 From cdded06ba3e7bb2c88dce2a8b2871acb1e6c60f8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 23 Jul 2026 01:04:37 +0000 Subject: [PATCH 027/117] Renamed the Web package as Infrastructure (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/25 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Packages/Infrastructure/Package.swift | 23 +- Packages/Infrastructure/README.md | 34 ++ .../Sources/Internal/Types/FNV1aHash.swift | 47 +++ .../Public/Enumerations/AssetExtension.swift | 53 ++++ .../Extensions/HTTPFieldName+Constants.swift | 4 +- .../Public/Extensions/Int+Constants.swift | 9 + .../Public/Extensions/String+Constants.swift | 23 ++ .../Public/Methods/FingerprintAssets.swift | 85 +++++ .../Middlewares/LocalizationMiddleware.swift | 10 +- .../Middlewares/NotFoundMiddleware.swift | 33 +- .../Middlewares/RateLimitMiddleware.swift | 291 ++++++++++++++++++ .../SecurityHeadersMiddleware.swift | 21 +- .../Public/Middlewares/VaryMiddleware.swift | 71 +++++ .../Sources/Public/Protocols/Asset.swift | 81 +++++ .../Protocols/LocalizedRequestContext.swift | 15 + .../Sources/Public/Protocols/Page.swift | 87 ++++++ .../Public/Responses/CachedHTMLResponse.swift | 114 +++++++ .../LocalizedHTMLCollectionResponse.swift | 31 +- .../Cases/Internal/Types/FNV1aHashTests.swift | 76 +++++ .../Enumerations/AssetExtensionTests.swift | 71 +++++ .../Methods/FingerprintAssetsTests.swift | 171 ++++++++++ .../LocalizationMiddlewareTests.swift | 69 +++++ .../Middlewares/NotFoundMiddlewareTests.swift | 191 ++++++++++++ .../RateLimitMiddlewareTests.swift | 170 ++++++++++ .../SecurityHeadersMiddlewareTests.swift | 42 ++- .../Middlewares/VaryMiddlewareTests.swift | 131 ++++++++ .../Cases/Public/Protocols/AssetTests.swift | 79 +++++ .../Cases/Public/Protocols/PageTests.swift | 62 ++++ .../Tests/Catalogs/Localizable.xcstrings | 23 ++ .../Tests/Utils/Assets/StubAsset.swift | 11 + .../Utils/Contexts/StubRequestContext.swift | 26 ++ .../Utils/Extensions/Tag+Constants.swift | 8 + .../Tests/Utils/Pages/StubPage.swift | 66 ++++ Services/Website/Dockerfile | 2 +- Services/Website/Package.swift | 1 + Services/Website/README.md | 12 +- .../Sources/App/Extensions/App+Build.swift | 27 +- .../Extensions/ConfigReader+Properties.swift | 37 ++- .../Internal/Enumerations/StaticFile.swift | 105 +------ .../Internal/Extensions/Page+Defaults.swift | 70 +++++ .../Library/Internal/Pages/ErrorPage.swift | 21 +- .../Library/Internal/Pages/IndexPage.swift | 21 +- .../Library/Internal/Protocols/Page.swift | 109 ------- .../Responses/CachedHTMLResponse.swift | 72 ----- ...text.swift => WebsiteRequestContext.swift} | 26 +- .../Public/Controllers/RootController.swift | 19 +- .../AbsoluteConfigKey+Constants.swift | 13 +- .../Extensions/ConfigKey+Constants.swift | 13 +- .../Public/Extensions/Int+Constants.swift | 4 +- .../LocalizationMiddleware+Defaults.swift | 13 + .../NotFoundMiddleware+Defaults.swift | 21 ++ .../Public/Extensions/String+Constants.swift | 21 -- Services/Website/Tests/App/AppTests.swift | 179 ++++++++++- .../Enumerations/StaticFileTests.swift | 124 +------- .../Cases/Internal/Pages/IndexPageTests.swift | 14 + .../Controllers/RootControllerTests.swift | 114 +++++++ .../LocalizationMiddlewareTests.swift | 2 + .../Middlewares/NotFoundMiddlewareTests.swift | 95 +++++- 58 files changed, 2844 insertions(+), 519 deletions(-) create mode 100644 Packages/Infrastructure/README.md create mode 100644 Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift create mode 100644 Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Extensions/HTTPFieldName+Constants.swift (72%) create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/LocalizationMiddleware.swift (87%) rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/NotFoundMiddleware.swift (62%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/SecurityHeadersMiddleware.swift (88%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Asset.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Page.swift create mode 100644 Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift rename {Services/Website/Sources/Library/Internal => Packages/Infrastructure/Sources/Public}/Responses/LocalizedHTMLCollectionResponse.swift (70%) create mode 100644 Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift rename {Services/Website/Tests/Library => Packages/Infrastructure/Tests}/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift (71%) create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift create mode 100644 Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings create mode 100644 Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift delete mode 100644 Services/Website/Sources/Library/Internal/Protocols/Page.swift delete mode 100644 Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift rename Services/Website/Sources/Library/Public/Contexts/{LocalizedRequestContext.swift => WebsiteRequestContext.swift} (57%) create mode 100644 Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift index 2f9227a..e399fba 100644 --- a/Packages/Infrastructure/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -16,6 +16,13 @@ let package = Package( ), ], dependencies: [ + .package( + path: "../Localization" + ), + .package( + url: "https://github.com/elementary-swift/elementary.git", + from: "0.6.0" + ), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" @@ -25,6 +32,11 @@ let package = Package( .target( name: "Infrastructure", dependencies: [ + .byName(name: "Localization"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "Hummingbird", package: "hummingbird" @@ -36,12 +48,21 @@ let package = Package( name: "InfrastructureTests", dependencies: [ .byName(name: "Infrastructure"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "HummingbirdTesting", package: "hummingbird" ), ], - path: "Tests" + path: "Tests", + resources: [ + // Copied verbatim rather than processed: the String Catalog is read as raw JSON at + // runtime so it resolves identically on Darwin and Linux (which cannot compile it). + .copy("Catalogs/Localizable.xcstrings") + ] ), ] ) diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md new file mode 100644 index 0000000..7913a19 --- /dev/null +++ b/Packages/Infrastructure/README.md @@ -0,0 +1,34 @@ +# Infrastructure +The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding. + +## Overview +The package provides, grouped by role: + +| Role | Types | +| --- | --- | +| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | +| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | +| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | +| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | +| Contexts | `LocalizedRequestContext` | + +## 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 (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. +- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. +- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +## Layout +Sources are split by visibility, then by kind, one type per file: + +``` +Sources/Public// public API (Protocols, Middlewares, Responses, …) +Sources/Internal// implementation details (e.g. FNV1aHash) +Tests/Cases/… mirrors the source layout +Tests/Utils/… stubs and test-only extensions +``` + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift new file mode 100644 index 0000000..c33baab --- /dev/null +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Hashes bytes with the FNV-1a 64-bit algorithm. +/// +/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it +/// suits values that must agree between instances and survive restarts: the asset version token +/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). +/// It is not cryptographic — a collision only risks serving a stale cached asset, not security. +struct FNV1aHash { + + // MARK: Properties + + /// The running hash value. + private var hash: UInt64 + + // MARK: Initializers + + /// Creates a hasher at the FNV-1a offset basis. + init() { + self.hash = 0xcbf2_9ce4_8422_2325 + } + + // MARK: Computed + + /// The hash of everything combined so far, as a fixed-width, 16-character hexadecimal token. + /// + /// Reading it does not consume the running hash: more bytes can be combined afterwards. + var digest: String { + String( + format: "%016llx", + hash + ) + } + + // MARK: Functions + + /// Folds the given bytes into the hash. + /// - Parameter bytes: the bytes to fold in. + mutating func combine( + _ bytes: some Sequence + ) { + for byte in bytes { + hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3 + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift new file mode 100644 index 0000000..8729209 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -0,0 +1,53 @@ +/// A file extension used by an ``Asset``. +/// +/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its +/// file name when resolving paths. +public enum AssetExtension: String, Sendable { + /// A Cascading Style Sheets file. + case css + /// A JavaScript file. + case js + /// A Portable Network Graphics image. + case png + /// A Windows icon image. + case ico + /// A Scalable Vector Graphics image. + case svg + /// A plain text file. + case txt + /// A web application manifest file. + case webmanifest + /// An Extensible Markup Language file. + case xml +} + +// MARK: - Extensions + +public extension AssetExtension { + + // MARK: Computed + + /// The file's content type. + var contentType: String { + switch self { + case .css: "text/css" + case .js: "text/javascript" + case .png: "image/png" + case .ico: "image/vnd.microsoft.icon" + case .svg: "image/svg+xml" + case .txt: "text/plain" + case .webmanifest: "application/manifest+json" + case .xml: "application/xml" + } + } + + /// The sub-directory within the static root that holds files with this extension, if any. + var subdirectory: String? { + switch self { + case .css: "css" + case .js: "js" + default: nil + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift similarity index 72% rename from Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift rename to Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift index 4ebf3bb..9f89f0a 100644 --- a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -1,10 +1,12 @@ import HTTPTypes -extension HTTPField.Name { +public extension HTTPField.Name { /// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`). static let permissionsPolicy = Self("Permissions-Policy")! /// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`). static let referrerPolicy = Self("Referrer-Policy")! /// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`). static let frameOptions = Self("X-Frame-Options")! + /// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`). + static let xForwardedFor = Self("X-Forwarded-For")! } diff --git a/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift new file mode 100644 index 0000000..b08ed06 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift @@ -0,0 +1,9 @@ +extension Int { + /// A namespace for the rate limit's default configuration values. + public enum RateLimit { + /// The default number of requests admitted per client per window. + public static let limit = 5 + /// The default window length, in seconds (1 minute). + public static let window = 60 + } +} diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..d01536e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -0,0 +1,23 @@ +extension String { + /// A namespace for the security headers' default configuration values. + /// + /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is + /// "sticky" in browsers, so it stays off unless explicitly configured in production. + public enum Security { + /// The default `Content-Security-Policy`. + /// + /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins + /// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing + /// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link + /// external stylesheets. + public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + /// The default `X-Content-Type-Options` (disables MIME sniffing). + public static let contentTypeOptions = "nosniff" + /// The default `X-Frame-Options` (forbids framing the page). + public static let frameOptions = "DENY" + /// The default `Referrer-Policy`. + public static let referrerPolicy = "strict-origin-when-cross-origin" + /// The default `Permissions-Policy` (denies access to powerful browser features a static site does not use). + public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + } +} diff --git a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift new file mode 100644 index 0000000..3a55d87 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -0,0 +1,85 @@ +import Foundation +import Logging + +/// Derives a version token from the contents of the static files directory. +/// +/// The token folds every file under the directory — its relative path and its bytes, in a stable order — into one FNV-1a digest, so it changes whenever any +/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=`), which lets the assets be +/// served with a long-lived, immutable cache policy: a deploy that changes an asset changes the URLs pointing at it, so no client ever revalidates or holds a +/// stale copy. +public struct FingerprintAssets: Sendable { + + // MARK: Properties + + /// The logger unreadable files are reported to, or `nil` to skip them silently. + private let logger: Logger? + + // MARK: Initializers + + /// Creates an asset fingerprinting method. + /// - Parameter logger: the logger unreadable files are reported to, or `nil` (the default) to skip them silently. + public init( + logger: Logger? = nil + ) { + self.logger = logger + } + + // MARK: Functions + + /// Fingerprints the static files under the given directory. + /// + /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its + /// later changes would not bust caches — a warning there usually points at a permissions + /// problem in the deployment. + /// - Parameter path: the directory the static files are served from. + /// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned). + public func callAsFunction( + _ path: String + ) -> String? { + let manager = FileManager.default + + guard let enumerated = manager.enumerator(atPath: path) else { + return nil + } + + // The path-based enumerator yields paths relative to the directory, so the token depends + // only on the directory's contents — never on where the directory itself lives (the + // URL-based enumerator standardizes symlinked bases, e.g. `/var/…` to `/private/var/…`, + // which would leak the absolute path into the hash). + var files: [String] = [] + + while let relativePath = enumerated.nextObject() as? String { + if enumerated.fileAttributes?[.type] as? FileAttributeType == .typeRegular { + files.append(relativePath) + } + } + + var hash = FNV1aHash() + var hashed = false + + for relativePath in files.sorted() { + guard let contents = manager.contents( + atPath: "\(path)/\(relativePath)" + ) else { + logger?.warning( + "Static file could not be read while fingerprinting; the version token will not reflect it.", + metadata: ["path": "\(relativePath)"] + ) + + continue + } + + hash.combine(Array(relativePath.utf8)) + hash.combine(contents) + + hashed = true + } + + guard hashed else { + return nil + } + + return hash.digest + } + +} diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift similarity index 87% rename from Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index db18270..c743aa8 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -1,3 +1,4 @@ +import Foundation import HTTPTypes import Hummingbird import Localization @@ -19,9 +20,12 @@ public struct LocalizationMiddleware { // MARK: Initializers - /// Creates a localization middleware that negotiates against the module's String Catalog languages. - public init() { - self.negotiate = .init(bundle: .module) + /// 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) } } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift similarity index 62% rename from Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift index 6d053aa..b5f51e6 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -1,11 +1,12 @@ +import Elementary +import Foundation import Hummingbird /// Serves a custom error page for requests that match neither a route nor a static file. /// -/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error -/// that bubbles up when no file exists for the requested path and responds with the rendered -/// ``ErrorPage`` 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. +/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested +/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by +/// ``LocalizationMiddleware``, falling back to the default language. public struct NotFoundMiddleware { // MARK: Properties @@ -16,12 +17,18 @@ public struct NotFoundMiddleware { // MARK: Initializers /// Creates a not-found middleware. - public init() { + /// - Parameters: + /// - bundle: the bundle whose String Catalog names the languages the page is rendered for. + /// - document: builds the error page to render for a given locale. + public init( + bundle: Bundle, + document: (Locale) -> Document + ) { self.responses = .init( - status: .notFound - ) { - ErrorPage(locale: $0) - } + bundle: bundle, + status: .notFound, + document: document + ) } } @@ -32,15 +39,14 @@ extension NotFoundMiddleware: RouterMiddleware { // MARK: Functions - /// Passes the request down the chain, rendering the error page if it results in a not-found - /// response. + /// Passes the request down the chain, rendering the error page if it results in a not-found response. /// /// Any error other than `.notFound` is rethrown unchanged. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. /// - next: the next responder in the middleware chain. - /// - Returns: the downstream response, or the rendered ``ErrorPage`` with a `404 Not Found` status. + /// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status. /// - Throws: any non-not-found error thrown downstream. public func handle( _ request: Request, @@ -59,7 +65,8 @@ extension NotFoundMiddleware: RouterMiddleware { } return responses.response( - for: context.language + for: context.language, + request: request ) } } diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift new file mode 100644 index 0000000..d1a0f19 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift @@ -0,0 +1,291 @@ +import Foundation +import HTTPTypes +import Hummingbird +import NIOCore +import Synchronization + +/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit. +/// +/// Added to the routes that must not be hammered — the subscription endpoint, an unauthenticated database write — it admits up to the configured limit of +/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the +/// window resets. +/// +/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by +/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the +/// store without bound — and each instance of a multi-instance deployment enforces its own budget. +public struct RateLimitMiddleware: Sendable { + + // MARK: Properties + + /// The fixed-window request counters, keyed by client. + private let buckets: Buckets + + /// The limits the middleware enforces. + private let configuration: Configuration + + // MARK: Initializers + + /// Creates a rate-limit middleware. + /// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per + /// client per minute. + public init( + configuration: Configuration = .init() + ) { + self.buckets = .init( + limit: configuration.limit, + window: configuration.window + ) + self.configuration = configuration + } + +} + +// MARK: - RouterMiddleware + +extension RateLimitMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After` + /// header once it does not. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response, or the `429` rejection. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + let admission = buckets.admit( + client( + for: request, + context: context + ) + ) + + switch admission { + case .admitted: + return try await next( + request, + context + ) + case .limited(let retryAfter): + var response = Response(status: .tooManyRequests) + + response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds)) + + return response + } + } + +} + +// MARK: - Helpers + +private extension RateLimitMiddleware { + + // MARK: Methods + + /// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one + /// bucket shared by every unidentifiable client when neither is known. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - Returns: the client key the request is counted under. + func client( + for request: Request, + context: Context + ) -> String { + if + configuration.trustForwardedFor, + let forwarded = request.headers[.xForwardedFor]? + .split(separator: ",") + .first? + .trimmingCharacters(in: .whitespaces), + !forwarded.isEmpty + { + return forwarded + } + + if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress { + return address.ipAddress + ?? address.description + } + + return .unidentified + } + +} + +// MARK: - Buckets + +private extension RateLimitMiddleware { + + /// The outcome of asking the ``Buckets`` store to admit a request. + enum Admission { + /// The request is within the client's budget. + case admitted + /// The client exhausted its budget; the payload is the time until its window resets. + case limited(retryAfter: Duration) + } + + /// The fixed-window request counters, keyed by client. + /// + /// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the + /// calling task never suspends — requests skip the executor hop an actor would add on every pass through the middleware. + final class Buckets: Sendable { + + // MARK: Properties + + /// The maximum number of clients tracked at once, bounding the store's memory. + private let capacity: Int + + /// The per-client counters: the start of the client's current window and its request count. + private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]> + + /// The number of requests admitted per client per ``window``. + private let limit: Int + + /// The length of the fixed window the ``limit`` applies to. + private let window: Duration + + // MARK: Initializers + + /// Creates a counter store. + /// - Parameters: + /// - limit: the number of requests admitted per client per window. + /// - window: the length of the fixed window the limit applies to. + /// - capacity: the maximum number of clients tracked at once. + init( + limit: Int, + window: Duration, + capacity: Int = 10_000 + ) { + self.capacity = capacity + self.counters = .init([:]) + self.limit = limit + self.window = window + } + + // MARK: Functions + + /// Counts a request against the client's current window and admits it while the count stays within the limit. + /// - Parameter client: the key the request is counted under. + /// - Returns: the admission outcome. + func admit( + _ client: String + ) -> Admission { + let now = ContinuousClock.now + + return counters.withLock { counters in + if let counter = counters[client], now < counter.start.advanced(by: window) { + guard counter.count < limit else { + return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window))) + } + + counters[client] = (counter.start, counter.count + 1) + + return .admitted + } + + makeRoom( + in: &counters, + at: now + ) + + counters[client] = (now, 1) + + return .admitted + } + } + + // MARK: Methods + + /// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the + /// oldest live windows are evicted in one batch — a tenth of the capacity — so the sort that finds them runs once per batch of admissions + /// instead of once per request while a flood of distinct clients keeps the store full. + /// - Parameters: + /// - counters: the counters the room is made in. + /// - now: the instant the expiry is evaluated against. + private func makeRoom( + in counters: inout [String: (start: ContinuousClock.Instant, count: Int)], + at now: ContinuousClock.Instant + ) { + guard counters.count >= capacity else { + return + } + + counters = counters.filter { + now < $0.value.start.advanced(by: window) + } + + let headroom = max(1, capacity / 10) + let excess = counters.count - (capacity - headroom) + + guard excess > 0 else { + return + } + + let oldest = counters + .sorted { $0.value.start < $1.value.start } + .prefix(excess) + + for counter in oldest { + counters.removeValue(forKey: counter.key) + } + } + + } + +} + +// MARK: - Configuration + +extension RateLimitMiddleware { + /// The limits a ``RateLimitMiddleware`` enforces. + public struct Configuration: Sendable { + + // MARK: Properties + + /// The number of requests admitted per client per ``window``. + public let limit: Int + + /// Whether a client is keyed by the first `X-Forwarded-For` entry. + /// + /// Enable it only behind a reverse proxy that sets the header — there, the connection's own address would name the proxy for every visitor, + /// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys + /// at will. + public let trustForwardedFor: Bool + + /// The length of the fixed window the ``limit`` applies to. + public let window: Duration + + // MARK: Initializers + + /// Creates a rate-limit configuration. + /// - Parameters: + /// - limit: the number of requests admitted per client per window. + /// - window: the length of the fixed window the limit applies to. + /// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry. + public init( + limit: Int = .RateLimit.limit, + window: Duration = .seconds(Int.RateLimit.window), + trustForwardedFor: Bool = false + ) { + self.limit = limit + self.trustForwardedFor = trustForwardedFor + self.window = window + } + + } +} + +// MARK: - String+Constants + +private extension String { + /// The bucket shared by every client the middleware cannot identify. + static let unidentified = "unidentified" +} diff --git a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift similarity index 88% rename from Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index 4bd48e9..ffa531f 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -4,7 +4,7 @@ import Hummingbird /// Stamps a set of security-related HTTP headers onto every response. /// /// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever -/// response bubbles back up — the rendered landing page, the ``ErrorPage`` produced by +/// response bubbles back up — the rendered pages, the error page produced by /// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies /// the strict, hardened interpretation of the content instead of its lenient legacy defaults. /// @@ -40,6 +40,9 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// Passes the request down the chain and stamps the configured security headers onto the /// response on the way back up. /// + /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the + /// controllers) are converted to their response here rather than left to the router: the router + /// converts them above the middleware chain, where the response would escape these headers. /// Existing values for the same header names are replaced so downstream middleware cannot leave /// a weaker policy in place. /// - Parameters: @@ -47,13 +50,25 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// - context: the context the request is resolved against. /// - next: the next responder in the middleware chain. /// - Returns: the downstream response with the security headers applied. - /// - Throws: any error thrown downstream. + /// - Throws: any downstream error that does not render as an HTTP response. public func handle( _ request: Request, context: Context, next: (Request, Context) async throws -> Response ) async throws -> Response { - var response = try await next(request, context) + var response: Response + + do { + response = try await next( + request, + context + ) + } catch let error as any HTTPResponseError { + response = try error.response( + from: request, + context: context + ) + } for field in fields { response.headers[field.name] = field.value diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift new file mode 100644 index 0000000..a500a1b --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift @@ -0,0 +1,71 @@ +import Foundation +import HTTPTypes +import Hummingbird + +/// Appends header names to the `Vary` header of every response passing through. +/// +/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered +/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that +/// never advertised support for the encoding. +/// +/// Names already present on a response's `Vary` header — such as the `Accept-Language` the localized pages carry — are kept, and duplicates are not +/// added. +public struct VaryMiddleware: Sendable { + + // MARK: Properties + + /// The header names appended to every response's `Vary` header. + private let names: [String] + + // MARK: Initializers + + /// Creates a vary middleware. + /// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header + /// the response-compression middleware acts on. + public init( + fields: [HTTPField.Name] = [.acceptEncoding] + ) { + self.names = fields.map(\.rawName) + } + +} + +// MARK: - RouterMiddleware + +extension VaryMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response with the `Vary` names applied. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + var response = try await next( + request, + context + ) + + var vary = response.headers[.vary]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } ?? [] + + for name in names where !vary.contains(where: { + $0.caseInsensitiveCompare(name) == .orderedSame + }) { + vary.append(name) + } + + response.headers[.vary] = vary.joined(separator: ", ") + + return response + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift new file mode 100644 index 0000000..6624516 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -0,0 +1,81 @@ +/// An asset shipped with a website: a file stored under the static files root and served by +/// Hummingbird's `FileMiddleware` middleware. +/// +/// A conforming asset supplies its file name and the extensions it is available with, each +/// resolving to its own file; the protocol derives the paths from them: the file's path within +/// the static files root and the URL path it is served at, optionally versioned to bust caches. +public protocol Asset: Sendable { + + // MARK: Properties + + /// The file extensions the asset is available with. + var fileExtensions: [AssetExtension] { get } + + /// The asset's file name, without extension. + var fileName: String { get } + +} + +// MARK: - Implementations + +public extension Asset { + + // MARK: Methods + + /// Resolves the asset's path against the given base directory. + /// + /// - Parameters: + /// - basePath: the directory the static files are served from. + /// - fileExtension: the extension of the file to resolve. + /// - Returns: the path to the file, relative to the `basePath` path. + func path( + relativeTo basePath: String, + for fileExtension: AssetExtension + ) -> String { + let relativePath = relativePath(for: fileExtension) + + guard !basePath.isEmpty else { + return relativePath + } + + return "\(basePath)/\(relativePath)" + } + + /// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`). + /// + /// This also matches the URL path the file is served at by `FileMiddleware`. + /// + /// - Parameter fileExtension: the extension of the file to resolve. + /// - Returns: the path to the file, relative to the static files root. + func relativePath( + for fileExtension: AssetExtension + ) -> String { + let file = "\(fileName).\(fileExtension.rawValue)" + + return fileExtension.subdirectory + .map { "\($0)/\(file)" } ?? file + } + + /// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`). + /// + /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): + /// `FileMiddleware` ignores the query when resolving the file, while caches key on the full + /// URL, so a deploy that changes the assets busts every cached copy at once. + /// - Parameters: + /// - fileExtension: the extension of the file to resolve. + /// - version: the version token to append, or `nil` to leave the URL unversioned. + /// - Returns: the path to use in `href` and `src` attributes. + func urlPath( + for fileExtension: AssetExtension, + version: String? = nil + ) -> String { + let path = "/\(relativePath(for: fileExtension))" + + guard let version, !version.isEmpty else { + return path + } + + return "\(path)?v=\(version)" + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift new file mode 100644 index 0000000..86fec3e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -0,0 +1,15 @@ +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 } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift new file mode 100644 index 0000000..1ab393e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -0,0 +1,87 @@ +import Elementary +import Foundation + +/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content. +/// +/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the +/// rest of the document around them: the viewport declaration and stylesheet links followed by the metadata in the head, and the content followed by +/// the script tags in the body. +public protocol Page: HTMLDocument, Sendable { + + // MARK: Associated types + + /// The type of the page's markup. + associatedtype Content: HTML + + /// The type of the page's head metadata markup. + associatedtype Metadata: HTML + + // MARK: Properties + + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + var assetVersion: String? { get } + + /// The page's markup, rendered before the ``scripts``. + @HTMLBuilder + var content: Content { get } + + /// The locale the page content is localized to. + var locale: Locale { get } + + /// The markup placed in the document head after the ``stylesheets``: icon and manifest + /// links, extra meta tags, and the like. + @HTMLBuilder + var metadata: Metadata { get } + + /// The scripts loaded at the end of the document body, in order. + var scripts: [any Asset] { get } + + /// The stylesheets linked in the document head, in order. + var stylesheets: [any Asset] { get } + +} + +// MARK: - Implementations + +public extension Page { + + // MARK: Computed + + /// The page ``content`` followed by its ``scripts``. + @HTMLBuilder + var body: some HTML { + content + + for file in scripts { + script(.src(file.urlPath( + for: .js, + version: assetVersion + ))) {} + } + } + + /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. + /// + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already + /// emits `` before this markup, and HTML5 allows only one. + @HTMLBuilder + var head: some HTML { + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + + metadata + + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath( + for: .css, + version: assetVersion + )) + ) + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift new file mode 100644 index 0000000..331defb --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift @@ -0,0 +1,114 @@ +import Elementary +import HTTPTypes +import Hummingbird +import NIOCore + +/// A pre-rendered HTTP response for a fully static HTML page. +/// +/// The document is rendered to bytes once, at initialization, and every ``response(for:)`` reuses those bytes — along with a fixed status and +/// precomputed headers — instead of re-rendering. This suits pages whose markup never changes between requests, such as the landing page and the +/// not-found page, avoiding a per-request Elementary render on hot paths. +/// +/// A successful page also revalidates cheaply: its headers carry a weak entity tag derived from the rendered bytes and a `Cache-Control` that asks +/// clients to revalidate (`no-cache`), so a repeat visit costs a `304 Not Modified` instead of a full transfer — and a deploy that changes the page +/// changes the tag, propagating immediately. +/// +/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language. +/// +/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the response-compression middleware downstream +/// treats it exactly as it would a freshly rendered page. +public struct CachedHTMLResponse: Sendable { + + // MARK: Properties + + /// The page rendered to bytes once. + private let buffer: ByteBuffer + + /// The weak entity tag of the rendered bytes, present on successful pages only. + private let eTag: String? + + /// The headers applied to every response, precomputed once. + private let headers: HTTPFields + + /// The status applied to every response. + private let status: HTTPResponse.Status + + // MARK: Initializers + + /// Renders the given document to bytes once. + /// + /// A `200 OK` page gets the revalidation headers (`ETag` and `Cache-Control`); an error page does not, since a `304 Not Modified` only + /// ever stands in for a success. + /// - Parameters: + /// - status: the status applied to every response. Defaults to `.ok`. + /// - additionalHeaders: extra headers merged onto every response, alongside the content type. + /// Used to carry per-language signals such as `Content-Language` and `Vary`. + /// - document: the static HTML document to render and cache. + public init( + status: HTTPResponse.Status = .ok, + additionalHeaders: HTTPFields = [:], + document: some HTMLDocument + ) { + let buffer = ByteBuffer(string: document.render()) + var headers: HTTPFields = [ + .contentType: "text/html; charset=utf-8" + ] + var eTag: String? + + if status == .ok { + var hash = FNV1aHash() + + hash.combine(buffer.readableBytesView) + + eTag = "W/\"\(hash.digest)\"" + + headers[.eTag] = eTag + headers[.cacheControl] = "public, no-cache" + } + + for field in additionalHeaders { + headers[field.name] = field.value + } + + self.buffer = buffer + self.eTag = eTag + self.headers = headers + self.status = status + } + + // MARK: Methods + + /// Builds a response from the cached, pre-rendered bytes. + /// + /// A conditional request whose `If-None-Match` names the page's entity tag is answered with a + /// bodyless `304 Not Modified`. Otherwise the full page is served, mirroring the + /// `text/html; charset=utf-8` content type `HTMLResponse` produces and leaving the + /// `Content-Length` unset so small pages remain eligible for compression. + /// - Parameter request: the request the response answers. + /// - Returns: the response carrying the cached HTML body, or its `304` revalidation. + public func response( + for request: Request + ) -> Response { + if + let eTag, + request.method == .get || request.method == .head, + let match = request.headers[.ifNoneMatch], + match == "*" || match.contains(eTag) + { + return Response( + status: .notModified, + headers: headers + ) + } + + return Response( + status: status, + headers: headers, + body: .init { [buffer] writer in + try await writer.write(buffer) + try await writer.finish(nil) + } + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift similarity index 70% rename from Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift rename to Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index e6772d1..b8c1161 100644 --- a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -10,11 +10,11 @@ import Localization /// 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. -struct LocalizedHTMLCollectionResponse: Sendable { +public struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Properties - /// The supported languages and default language, derived from the module's String Catalog. + /// The supported languages and default language, derived from the bundle's String Catalog. private let list: LanguageList /// The pre-rendered responses, keyed by language identifier. @@ -24,13 +24,15 @@ struct LocalizedHTMLCollectionResponse: Sendable { /// Renders the document once per supported language. /// - Parameters: + /// - bundle: the bundle whose String Catalog names the languages the document is rendered for. /// - status: the status applied to every response. Defaults to `.ok`. /// - document: builds the document to render for a given locale. - init( + public init( + bundle: Bundle, status: HTTPResponse.Status = .ok, document: (Locale) -> Document ) { - self.list = .init(bundle: .module) + self.list = .init(bundle: bundle) self.responses = list.all .reduce(into: [:]) { responses, language in responses[language] = CachedHTMLResponse( @@ -39,7 +41,9 @@ struct LocalizedHTMLCollectionResponse: Sendable { .contentLanguage: language, .vary: "Accept-Language", ], - document: document(.init(identifier: language)) + document: document(.init( + identifier: language + )) ) } } @@ -47,19 +51,26 @@ struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Methods /// Builds the response for the given language, falling back to the default language. - /// - Parameter language: the negotiated language identifier. + /// - Parameters: + /// - language: the negotiated language identifier. + /// - request: the request the response answers, consulted for conditional revalidation. /// - Returns: the cached response for the language, the default language's response when the /// language is unavailable, or a `500 Internal Server Error` if neither is cached. - func response( - for language: String + public func response( + for language: String, + request: Request ) -> Response { guard let response = responses[language] ?? responses[list.default] else { - return .init(status: .internalServerError) + return .init( + status: .internalServerError + ) } - return response.response() + return response.response( + for: request + ) } } diff --git a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift new file mode 100644 index 0000000..e876252 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift @@ -0,0 +1,76 @@ +import Testing + +@testable import Infrastructure + +@Suite("FNV1aHash type") +struct FNV1aHashTests { + + // MARK: Functional tests + + @Test(arguments: [ + ("", "cbf29ce484222325"), + ("a", "af63dc4c8601ec8c"), + ("b", "af63df4c8601f1a5"), + ("foobar", "85944171f73967e8"), + ]) + func `matches the published FNV-1a 64-bit test vectors`( + input: String, + digest: String + ) { + var hash = FNV1aHash() + + hash.combine(input.utf8) + + #expect(hash.digest == digest) + } + + @Test + func `pads the digest to sixteen characters`() { + var hash = FNV1aHash() + + // "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep. + hash.combine("aa".utf8) + + #expect(hash.digest == "089c4307b54596b7") + #expect(hash.digest.count == 16) + } + + @Test + func `hashes incrementally combined bytes as one stream`() { + var combined = FNV1aHash() + var whole = FNV1aHash() + + combined.combine("foo".utf8) + combined.combine("bar".utf8) + whole.combine("foobar".utf8) + + #expect(combined.digest == whole.digest) + } + + @Test + func `distinguishes the order of the combined bytes`() { + var forward = FNV1aHash() + var backward = FNV1aHash() + + forward.combine("ab".utf8) + backward.combine("ba".utf8) + + #expect(forward.digest != backward.digest) + } + + @Test + func `digests without consuming the running hash`() { + var hash = FNV1aHash() + + hash.combine("foo".utf8) + + let first = hash.digest + + #expect(hash.digest == first) + + hash.combine("bar".utf8) + + #expect(hash.digest != first) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift new file mode 100644 index 0000000..98730b5 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -0,0 +1,71 @@ +import Testing + +@testable import Infrastructure + +@Suite("AssetExtension enumeration") +struct AssetExtensionTests { + + // MARK: Computed tests + + @Test(arguments: zip( + Self.extensions, + Self.contentTypes + )) + func `content type`( + for fileExtension: AssetExtension, + expects contentType: String + ) { + #expect(fileExtension.contentType == contentType) + } + + @Test(arguments: zip( + Self.extensions, + Self.subdirectories + )) + func `subdirectory`( + for fileExtension: AssetExtension, + expects subdirectory: String? + ) { + #expect(fileExtension.subdirectory == subdirectory) + } + +} + +// MARK: - Helpers + +private extension AssetExtensionTests { + + // MARK: Constants + + static let extensions: [AssetExtension] = [ + .css, + .js, + .png, + .ico, + .svg, + .txt, + .webmanifest, + .xml + ] + static let contentTypes: [String] = [ + "text/css", + "text/javascript", + "image/png", + "image/vnd.microsoft.icon", + "image/svg+xml", + "text/plain", + "application/manifest+json", + "application/xml" + ] + static let subdirectories: [String?] = [ + "css", + "js", + nil, + nil, + nil, + nil, + nil, + nil + ] + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift new file mode 100644 index 0000000..5c073e8 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift @@ -0,0 +1,171 @@ +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("FingerprintAssets method") +struct FingerprintAssetsTests { + + // MARK: Properties + + private let fingerprint = FingerprintAssets() + + // MARK: Functional tests + + @Test + func `fingerprints the files under a directory`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }", + "robots.txt": "User-agent: *" + ]) + + defer { + removeDirectory(directory) + } + + let token = try #require(fingerprint(directory.path)) + + #expect(token.count == 16) + #expect(token.allSatisfy { $0.isHexDigit }) + } + + @Test + func `agrees across directories with identical contents`() throws { + let files = [ + "css/site.css": "body { margin: 0; }", + "js/site.js": "console.log(1);" + ] + let first = try makeDirectory(files: files) + let second = try makeDirectory(files: files) + + defer { + removeDirectory(first) + removeDirectory(second) + } + + #expect(fingerprint(first.path) == fingerprint(second.path)) + } + + @Test + func `changes the token when a file's contents change`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }" + ]) + + defer { + removeDirectory(directory) + } + + let before = fingerprint(directory.path) + + try "body { margin: 1px; }".write( + to: directory.appendingPathComponent("css/site.css"), + atomically: true, + encoding: .utf8 + ) + + #expect(fingerprint(directory.path) != before) + } + + @Test + func `changes the token when a file is renamed`() throws { + let contents = "body { margin: 0; }" + let first = try makeDirectory(files: ["css/site.css": contents]) + let second = try makeDirectory(files: ["css/main.css": contents]) + + defer { + removeDirectory(first) + removeDirectory(second) + } + + #expect(fingerprint(first.path) != fingerprint(second.path)) + } + + @Test + func `changes the token when a file is added`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }" + ]) + + defer { + removeDirectory(directory) + } + + let before = fingerprint(directory.path) + + try "console.log(1);".write( + to: directory.appendingPathComponent("site.js"), + atomically: true, + encoding: .utf8 + ) + + #expect(fingerprint(directory.path) != before) + } + + @Test + func `returns nil for a directory without files`() throws { + let directory = try makeDirectory(files: [:]) + + defer { + removeDirectory(directory) + } + + #expect(fingerprint(directory.path) == nil) + } + + @Test + func `returns nil for a missing directory`() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)") + + #expect(fingerprint(missing.path) == nil) + } + +} + +// MARK: - Helpers + +private extension FingerprintAssetsTests { + + // MARK: Methods + + /// Creates a unique temporary directory holding the given files, keyed by relative path. + /// - Parameter files: the files to create, keyed by their path relative to the directory. + /// - Returns: the URL of the created directory. + func makeDirectory( + files: [String: String] + ) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)") + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + for (relativePath, contents) in files { + let file = directory.appendingPathComponent(relativePath) + + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write( + to: file, + atomically: true, + encoding: .utf8 + ) + } + + return directory + } + + /// Removes a temporary directory created by ``makeDirectory(files:)``. + /// - Parameter directory: the URL of the directory to remove. + func removeDirectory( + _ directory: URL + ) { + try? FileManager.default.removeItem(at: directory) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift new file mode 100644 index 0000000..c18276e --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -0,0 +1,69 @@ +import Foundation +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import Infrastructure + +@Suite("LocalizationMiddleware middleware", .tags(.middleware)) +struct LocalizationMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + } + + router.get("language") { _, context in + context.language + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `negotiates a supported language from the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "de-DE,de;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + @Test + func `falls back to the default without a header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + + @Test + func `falls back to the default for an unsupported language`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "fr-FR,fr;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift new file mode 100644 index 0000000..895d90c --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import Infrastructure + +@Suite("NotFoundMiddleware middleware", .tags(.middleware)) +struct NotFoundMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + NotFoundMiddleware(bundle: .module) { + StubPage(locale: $0) + } + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get("boom") { _, _ -> String in + throw HTTPError(.badRequest) + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `renders the error page for an unmatched request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(response.headers[.contentLanguage] == "en") + #expect(response.headers[.vary] == "Accept-Language") + #expect(body.contains("Stub content")) + } + } + } + + @Test + func `renders the error page in the negotiated language`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.acceptLanguage: "de-DE,de;q=0.9"] + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.contentLanguage] == "de") + } + } + } + + @Test + func `passes a matched response through untouched`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } + + @Test + func `rethrows a non-not-found error unchanged`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/boom", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .badRequest) + #expect(!body.contains("Stub content")) + } + } + } + + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/stub.css?v=0123456789abcdef")) + #expect(body.contains("/js/stub.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/stub.css""#)) + #expect(!body.contains("?v=")) + } + } + } + + @Test + func `serves the error page without revalidation headers`() async throws { + // A `304 Not Modified` only ever stands in for a success, so the error page must not + // invite revalidation with an entity tag or a cache policy. + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.eTag] == nil) + #expect(response.headers[.cacheControl] == nil) + } + } + } + + @Test + func `serves the full error page to a conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.ifNoneMatch: "*"] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(body.contains("Stub content")) + } + } + } + +} + +// MARK: - Helpers + +private extension NotFoundMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose not-found middleware appends the given version token to the + /// error page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + NotFoundMiddleware(bundle: .module) { + StubPage( + locale: $0, + assetVersion: assetVersion + ) + } + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift new file mode 100644 index 0000000..5c6bd32 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -0,0 +1,170 @@ +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite("RateLimitMiddleware middleware", .tags(.middleware)) +struct RateLimitMiddlewareTests { + + // MARK: Functional tests + + @Test + func `admits requests within the limit`() async throws { + try await app( + configuration: .init(limit: 3) + ).test(.router) { client in + for _ in 1 ... 3 { + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + } + + @Test + func `rejects a request over the limit with a retry-after header`() async throws { + try await app( + configuration: .init(limit: 2) + ).test(.router) { client in + for _ in 1 ... 2 { + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .tooManyRequests) + + let retryAfter = try #require(response.headers[.retryAfter]) + + #expect(try #require(Int(retryAfter)) >= 1) + } + } + } + + @Test + func `admits requests again once the window resets`() async throws { + try await app( + configuration: .init( + limit: 1, + window: .milliseconds(50) + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .tooManyRequests) + } + + try await Task.sleep(for: .milliseconds(100)) + + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `separates clients by their forwarded address when trusted`() async throws { + try await app( + configuration: .init( + limit: 1, + trustForwardedFor: true + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7"] + ) { response in + #expect(response.status == .ok) + } + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.8"] + ) { response in + #expect(response.status == .ok) + } + // The first entry names the client; the appended proxy hop must not change its key. + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"] + ) { response in + #expect(response.status == .tooManyRequests) + } + } + } + + @Test + func `ignores the forwarded address when not trusted`() async throws { + try await app( + configuration: .init(limit: 1) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7"] + ) { response in + #expect(response.status == .ok) + } + // Without trust (and without a connection address in router-only testing), every client + // shares one bucket, so a rotated header must not mint a fresh budget. + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.8"] + ) { response in + #expect(response.status == .tooManyRequests) + } + } + } + +} + +// MARK: - Helpers + +private extension RateLimitMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the rate-limit middleware ahead of a single + /// `/hello` route returning a plain body. + func app( + configuration: RateLimitMiddleware.Configuration + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + RateLimitMiddleware(configuration: configuration) + } + + router.get("hello") { _, _ in + "Hello!" + } + + return Application(router: router) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift similarity index 71% rename from Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index e987adc..78db8c0 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -2,7 +2,7 @@ import Hummingbird import HummingbirdTesting import Testing -@testable import WebsiteLibrary +@testable import Infrastructure @Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) struct SecurityHeadersMiddlewareTests { @@ -17,11 +17,11 @@ struct SecurityHeadersMiddlewareTests { method: .get ) { response in #expect(response.status == .ok) - #expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy) - #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) - #expect(response.headers[.frameOptions] == String.Security.frameOptions) - #expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy) - #expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy) + #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + #expect(response.headers[.frameOptions] == .Security.frameOptions) + #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy) + #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy) } } } @@ -91,7 +91,24 @@ struct SecurityHeadersMiddlewareTests { uri: "/weak", method: .get ) { response in - #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + } + } + } + + @Test + func `applies the security headers to an error response`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/throws", + method: .get + ) { response in + #expect(response.status == .badRequest) + #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + #expect(response.headers[.frameOptions] == .Security.frameOptions) + #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy) + #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy) } } } @@ -104,9 +121,10 @@ private extension SecurityHeadersMiddlewareTests { // MARK: Methods - /// Builds an application whose router applies the security-headers middleware ahead of two - /// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a - /// deliberately weak `X-Content-Type-Options` value for the middleware to override. + /// Builds an application whose router applies the security-headers middleware ahead of three + /// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a + /// deliberately weak `X-Content-Type-Options` value for the middleware to override, and + /// `/throws` fails with an `HTTPError` the way the controllers do on invalid input. func app( configuration: SecurityHeadersMiddleware.Configuration = .init() ) -> some ApplicationProtocol { @@ -128,6 +146,10 @@ private extension SecurityHeadersMiddlewareTests { return response } + router.get("throws") { _, _ -> Response in + throw HTTPError(.badRequest) + } + return Application(router: router) } diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift new file mode 100644 index 0000000..afa7dad --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift @@ -0,0 +1,131 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite("VaryMiddleware middleware", .tags(.middleware)) +struct VaryMiddlewareTests { + + // MARK: Functional tests + + @Test + func `adds the default field to a response without a vary header`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/plain", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.vary] == "Accept-Encoding") + } + } + } + + @Test + func `appends to an existing vary header`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/localized", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Language, Accept-Encoding") + } + } + } + + @Test + func `does not duplicate a name already present`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/encoded", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Encoding") + } + } + } + + @Test + func `matches an existing name regardless of its casing`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/lowercased", + method: .get + ) { response in + #expect(response.headers[.vary] == "accept-encoding") + } + } + } + + @Test + func `normalizes the whitespace of an existing list`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/spaced", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding") + } + } + } + + @Test + func `appends every configured field`() async throws { + try await app( + fields: [.acceptEncoding, .acceptLanguage] + ).test(.router) { client in + try await client.execute( + uri: "/plain", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Encoding, Accept-Language") + } + } + } + +} + +// MARK: - Helpers + +private extension VaryMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the vary middleware ahead of routes whose + /// responses carry different `Vary` starting points: `/plain` none, `/localized` an + /// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase + /// `accept-encoding`, and `/spaced` a list with irregular whitespace. + func app( + fields: [HTTPField.Name] = [.acceptEncoding] + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + VaryMiddleware(fields: fields) + } + + router.get("plain") { _, _ in + "Hello!" + } + + for (path, vary) in [ + ("localized", "Accept-Language"), + ("encoded", "Accept-Encoding"), + ("lowercased", "accept-encoding"), + ("spaced", "Accept-Language , User-Agent"), + ] { + router.get(RouterPath(path)) { _, _ -> Response in + var response = Response(status: .ok) + + response.headers[.vary] = vary + + return response + } + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift new file mode 100644 index 0000000..ea9c778 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import Infrastructure + +@Suite("Asset protocol") +struct AssetTests { + + // MARK: Properties + + private let image = StubAsset( + fileExtensions: [.png], + fileName: "icon" + ) + private let shared = StubAsset( + fileExtensions: [.css, .js], + fileName: "shared" + ) + + // MARK: Method tests + + @Test + func `relative path nests the file inside its extension's sub-directory`() { + #expect(shared.relativePath(for: .css) == "css/shared.css") + #expect(shared.relativePath(for: .js) == "js/shared.js") + } + + @Test + func `relative path keeps the file at the root without a sub-directory`() { + #expect(image.relativePath(for: .png) == "icon.png") + } + + @Test + func `url path prefixes the relative path with a slash`() { + #expect(shared.urlPath(for: .css) == "/css/shared.css") + #expect(image.urlPath(for: .png) == "/icon.png") + } + + @Test + func `url path appends a version token as a query parameter`() { + #expect(shared.urlPath( + for: .css, + version: "0123456789abcdef" + ) == "/css/shared.css?v=0123456789abcdef") + } + + @Test(arguments: [nil, ""] as [String?]) + func `url path without a version`( + version: String? + ) { + #expect(shared.urlPath( + for: .css, + version: version + ) == "/css/shared.css") + } + + @Test(arguments: [ + "", + ".", + "Resources/Static" + ]) + func `path relative to`( + _ basePath: String + ) { + for fileExtension in shared.fileExtensions { + let pathRelativeToBasePath = shared.path( + relativeTo: basePath, + for: fileExtension + ) + let relativePath = shared.relativePath(for: fileExtension) + + if basePath.isEmpty { + #expect(pathRelativeToBasePath == relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") + } + } + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift new file mode 100644 index 0000000..a09a533 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -0,0 +1,62 @@ +import Elementary +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("Page protocol", .tags(.page)) +struct PageTests { + + // MARK: Functional tests + + @Test + func `assembles the document around the page's parts`() { + let html = StubPage().render() + + #expect(html.contains("Stub Page")) + #expect(html.contains(#"lang="en""#)) + #expect(html.contains(#"name="viewport""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains("Stub content")) + } + + @Test + func `places the metadata between the viewport and the stylesheets`() throws { + let html = StubPage().render() + + let viewport = try #require(html.range(of: #"name="viewport""#)) + let metadata = try #require(html.range(of: #"name="stub""#)) + let stylesheet = try #require(html.range(of: "/css/stub.css")) + + #expect(viewport.lowerBound < metadata.lowerBound) + #expect(metadata.lowerBound < stylesheet.lowerBound) + } + + @Test + func `renders the scripts after the content`() throws { + let html = StubPage().render() + + let content = try #require(html.range(of: "Stub content")) + let script = try #require(html.range(of: "/js/stub.js")) + + #expect(content.lowerBound < script.lowerBound) + } + + @Test + func `appends the version token to the asset URLs`() { + let html = StubPage(assetVersion: "0123456789abcdef").render() + + #expect(html.contains("/css/stub.css?v=0123456789abcdef")) + #expect(html.contains("/js/stub.js?v=0123456789abcdef")) + } + + @Test + func `derives the document language from the locale`() { + let html = StubPage(locale: .init(identifier: "de-DE")).render() + + #expect(html.contains(#"lang="de""#)) + } + +} diff --git a/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..897a3e0 --- /dev/null +++ b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings @@ -0,0 +1,23 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "test.greeting" : { + "comment" : "Fixture string used by the Infrastructure test suite.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hallo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift new file mode 100644 index 0000000..c508008 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift @@ -0,0 +1,11 @@ +import Infrastructure + +/// An ``Asset`` with a fixed file name and set of extensions. +struct StubAsset: Asset { + + // MARK: Properties + + let fileExtensions: [AssetExtension] + let fileName: String + +} diff --git a/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift new file mode 100644 index 0000000..aa28122 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift @@ -0,0 +1,26 @@ +import Hummingbird +import Infrastructure + +/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only. +struct StubRequestContext: LocalizedRequestContext { + + // MARK: Properties + + /// The core request context storage Hummingbird requires. + var coreContext: CoreRequestContextStorage + + /// The language identifier negotiated for the request. + var language: String + + // MARK: Initializers + + /// Creates a request context for the given source. + /// - Parameter source: the source the context is initialized from. + init( + source: Source + ) { + self.coreContext = .init(source: source) + self.language = "" + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..e83de46 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,8 @@ +import Testing + +extension Tag { + /// Tests exercising a middleware of the Infrastructure package. + @Tag static var middleware: Tag + /// Tests exercising the page scaffolding of the Infrastructure package. + @Tag static var page: Tag +} diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift new file mode 100644 index 0000000..eae187d --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -0,0 +1,66 @@ +import Elementary +import Foundation +import Infrastructure + +/// A ``Page`` with fixed content, metadata, and stub assets. +struct StubPage: Page { + + // MARK: Properties + + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + let assetVersion: String? + + /// The locale the page content is localized to. + let locale: Locale + + // MARK: Initializers + + /// Creates a stub page. + /// - Parameters: + /// - locale: the locale the page content is localized to. Defaults to `en`. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the + /// default) to leave them unversioned. + init( + locale: Locale = .init(identifier: "en"), + assetVersion: String? = nil + ) { + self.assetVersion = assetVersion + self.locale = locale + } + + // MARK: Computed + + var content: some HTML { + p { "Stub content" } + } + + var lang: String { + locale.language.languageCode?.identifier ?? "en" + } + + var metadata: some HTML { + meta( + .name("stub"), + .content("marker") + ) + } + + var scripts: [any Asset] { + [StubAsset( + fileExtensions: [.css, .js], + fileName: "stub" + )] + } + + var stylesheets: [any Asset] { + [StubAsset( + fileExtensions: [.css, .js], + fileName: "stub" + )] + } + + var title: String { + "Stub Page" + } + +} diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index f0fef04..2570487 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -55,9 +55,9 @@ RUN swift package --package-path ./Services/Website resolve # Copy only the Swift inputs needed for a release build. Static assets are built # in the assets stage and copied into staging after the binary is produced. +COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources -COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 72dd107..0163147 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -108,6 +108,7 @@ let package = Package( .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Website"), .product( name: "HummingbirdTesting", diff --git a/Services/Website/README.md b/Services/Website/README.md index 8178dfc..9eef3ae 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -26,7 +26,7 @@ Two SwiftPM targets: The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. +- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). @@ -65,7 +65,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c ### 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.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=`), so a deploy busts them by changing the URL. | +| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for unversioned text assets (e.g. `robots.txt`); 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). | @@ -106,6 +107,13 @@ See [Persistence](#persistence-1) below for the workflow. | --- | --- | --- | --- | | `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. | +### Rate limiting +| Config key | Environment variable | Default | Description | +| --- | --- | --- | --- | +| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. | +| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. | +| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. | + ### Security headers | Config key | Environment variable | Default | | --- | --- | --- | diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index e7bd7bc..d4f66ed 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -26,6 +26,8 @@ func application( logger: logger ) let fluent = persistence() + + let fingerprintAssets = FingerprintAssets(logger: logger) let prepareDB = PrepareDB() await prepareDB(for: fluent) @@ -33,8 +35,10 @@ func application( var app = Application( router: router( staticFilesPath: reader.staticFilesPath, + assetVersion: fingerprintAssets(reader.staticFilesPath), cacheControl: reader.cacheControl, compressionMinResponseSize: reader.compressionMinResponseSize, + rateLimit: reader.rateLimit, securityHeaders: reader.securityHeaders, logLevel: reader.logLevel, probe: Probe(fluent: fluent) @@ -117,25 +121,31 @@ private func logger( /// Builds the application's router. /// /// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the -/// response-compression middleware that compresses responses larger than `minimumResponseSizeToCompress` when the client advertises support, -/// the localization middleware that negotiates the request's language from its `Accept-Language` header, 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 and the `HealthController` routes that serve the health check. +/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses +/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's +/// language from its `Accept-Language` header, 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, the `SubscriptionController` routes that register newsletter subscriptions, 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 served static files. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned. /// - cacheControl: the cache-control directives applied to the served static files. /// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. +/// - rateLimit: the rate limit applied to the subscription endpoint. /// - securityHeaders: the security headers applied to every response. /// - logLevel: the level the request-logging middleware logs at. /// - probe: the probe consulted by the `HealthController` readiness route. /// - Returns: the configured router. private func router( staticFilesPath: String, + assetVersion: String?, cacheControl: CacheControl, compressionMinResponseSize: Int, + rateLimit: RateLimitMiddleware.Configuration, securityHeaders: SecurityHeadersMiddleware.Configuration, logLevel: Logger.Level, probe: Probe @@ -152,11 +162,14 @@ private func router( SecurityHeadersMiddleware( configuration: securityHeaders ) + VaryMiddleware() ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) LocalizationMiddleware() - NotFoundMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) FileMiddleware( staticFilesPath, cacheControl: cacheControl @@ -164,7 +177,9 @@ private func router( } router.addController { - RootController() + RootController( + assetVersion: assetVersion + ) HealthController( probe: probe ) diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 2f9fdc2..9acbbe5 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -1,5 +1,6 @@ import Configuration import Hummingbird +import Infrastructure import Logging import Persistence import WebsiteLibrary @@ -15,9 +16,16 @@ package extension ConfigReader { /// The `Cache-Control` policy applied to static files, grouped by media type. /// - /// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS, - /// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone. + /// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and + /// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and + /// fonts are immutable subset files, so all three are served long-lived and `immutable` — a deploy busts them by changing the URL, never by + /// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and + /// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category. var cacheControl: CacheControl { + let maxAgeAsset = int( + forKey: .Cache.maxAgeAsset, + default: .Cache.maxAgeAsset + ) let maxAgeDefault = int( forKey: .Cache.maxAgeDefault, default: .Cache.maxAgeDefault @@ -32,6 +40,9 @@ package extension ConfigReader { ) return .init([ + (.textCss, [.public, .maxAge(maxAgeAsset), .immutable]), + (.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]), + (.font, [.public, .maxAge(maxAgeAsset), .immutable]), (.text, [.public, .maxAge(maxAgeText), .mustRevalidate]), (.image, [.public, .maxAge(maxAgeImage)]), (.init(type: .any), [.public, .maxAge(maxAgeDefault)]), @@ -110,6 +121,28 @@ package extension ConfigReader { ) } + /// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys. + /// + /// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When + /// `rateLimit.trustForwardedFor` is set, clients are keyed by the first `X-Forwarded-For` entry — + /// enable it only behind a reverse proxy that sets the header, since clients can forge it otherwise. + var rateLimit: RateLimitMiddleware.Configuration { + .init( + limit: int( + forKey: .RateLimit.limit, + default: .RateLimit.limit + ), + window: .seconds(int( + forKey: .RateLimit.window, + default: .RateLimit.window + )), + trustForwardedFor: bool( + forKey: .RateLimit.trustForwardedFor, + default: false + ) + ) + } + /// The security headers middleware configuration, built from the `security.*` keys. /// /// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity` diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 69afb22..d953601 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,9 +1,11 @@ +import Infrastructure + /// A static file shipped with the website service. /// /// Each case identifies a file name stored under the static files root (the `Resources/Static` /// directory) and served by Hummingbird's `FileMiddleware` middleware. A name can be available /// with more than one extension (see ``fileExtensions``), each resolving to its own file. -enum StaticFile: CaseIterable, Sendable { +enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. @@ -28,30 +30,6 @@ enum StaticFile: CaseIterable, Sendable { case sitemap } -// MARK: - Enumerations - -extension StaticFile { - /// A file extension used by a ``StaticFile``. - enum Extension: String, Sendable { - /// A Cascading Style Sheets file. - case css - /// A JavaScript file. - case js - /// A Portable Network Graphics image. - case png - /// A Windows icon image. - case ico - /// A Scalable Vector Graphics image. - case svg - /// A plain text file. - case txt - /// A web application manifest file. - case webmanifest - /// An Extensible Markup Language file. - case xml - } -} - // MARK: - Extensions extension StaticFile { @@ -59,7 +37,7 @@ extension StaticFile { // MARK: Computed /// The file extensions the file is available with. - var fileExtensions: [Extension] { + var fileExtensions: [AssetExtension] { switch self { case .appleTouchIcon, .icon192, @@ -92,79 +70,4 @@ extension StaticFile { } } - // MARK: Methods - - /// Resolves the file's path against the given base directory. - /// - /// - Parameters: - /// - basePath: the directory the static files are served from. - /// - fileExtension: the extension of the file to resolve. - /// - Returns: the path to the file, relative to the `basePath` path. - func path( - relativeTo basePath: String, - for fileExtension: Extension - ) -> String { - let relativePath = relativePath(for: fileExtension) - - guard !basePath.isEmpty else { - return relativePath - } - - return "\(basePath)/\(relativePath)" - } - - /// Resolves the file's path relative to the static files root (e.g. `"css/shared.css"`). - /// - /// This also matches the URL path the file is served at by `FileMiddleware`. - /// - /// - Parameter fileExtension: the extension of the file to resolve. - /// - Returns: the path to the file, relative to the static files root. - func relativePath( - for fileExtension: Extension - ) -> String { - let file = "\(fileName).\(fileExtension.rawValue)" - - return fileExtension.subdirectory - .map { "\($0)/\(file)" } ?? file - } - - /// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`). - /// - /// - Parameter fileExtension: the extension of the file to resolve. - /// - Returns: the path to use in `href` and `src` attributes. - func urlPath( - for fileExtension: Extension - ) -> String { - "/\(relativePath(for: fileExtension))" - } - -} - -extension StaticFile.Extension { - - // MARK: Computed - - /// The file's content type. - var contentType: String { - switch self { - case .css: "text/css" - case .js: "text/javascript" - case .png: "image/png" - case .ico: "image/vnd.microsoft.icon" - case .svg: "image/svg+xml" - case .txt: "text/plain" - case .webmanifest: "application/manifest+json" - case .xml: "application/xml" - } - } - - /// The sub-directory within the static root that holds files with this extension, if any. - var subdirectory: String? { - switch self { - case .css: "css" - case .js: "js" - default: nil - } - } - } diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift new file mode 100644 index 0000000..71eefba --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -0,0 +1,70 @@ +import Elementary +import Foundation +import Infrastructure +import Localization + +/// The site-wide defaults shared by every page of the website. +extension Page { + + // MARK: Computed + + /// The document language, derived from the page's locale and falling back to the default language. + var lang: String { + locale.language.languageCode?.identifier + ?? LanguageList(bundle: .module).default + } + + /// The icon, manifest, and theme colour metadata shared by every page of the website. + @HTMLBuilder + var metadata: some HTML { + link( + .rel(.icon), + .href(StaticFile.favicon.urlPath( + for: .ico, + version: assetVersion + )), + .custom( + name: "sizes", + value: "any" + ) + ) + link( + .rel(.icon), + .href(StaticFile.icon.urlPath( + for: .svg, + version: assetVersion + )), + .custom( + name: "type", + value: "image/svg+xml" + ) + ) + link( + .rel("apple-touch-icon"), + .href(StaticFile.appleTouchIcon.urlPath( + for: .png, + version: assetVersion + )) + ) + link( + .rel("manifest"), + .href(StaticFile.site.urlPath( + for: .webmanifest, + version: assetVersion + )) + ) + meta( + .name("theme-color"), + .content("#fafafa") + ) + meta( + .name("theme-color"), + .content("#0c0710"), + .custom( + name: "media", + value: "(prefers-color-scheme: dark)" + ) + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 3f332fb..2b19f7b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -1,5 +1,6 @@ import Elementary import Foundation +import Infrastructure import Localization /// The HTML page rendered for a not-found response, with its text localized to a given locale. @@ -7,6 +8,9 @@ struct ErrorPage { // MARK: Properties + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + let assetVersion: String? + /// The locale the page content is localized to. let locale: Locale @@ -16,10 +20,15 @@ struct ErrorPage { // MARK: Initializers /// Creates a not-found page localized to the given locale. - /// - Parameter locale: the locale the page content is localized to. + /// - Parameters: + /// - locale: the locale the page content is localized to. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the + /// default) to leave them unversioned. init( - locale: Locale + locale: Locale, + assetVersion: String? = nil ) { + self.assetVersion = assetVersion self.locale = locale self.localize = .init(bundle: .module) } @@ -40,12 +49,12 @@ extension ErrorPage: Page { } } - var scripts: [StaticFile] { - [.error, .shared] + var scripts: [any Asset] { + [StaticFile.error, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .error] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.error] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index aabef0b..bcb0475 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -1,5 +1,6 @@ import Elementary import Foundation +import Infrastructure import Localization /// The website's landing page, with its text localized to a given locale. @@ -7,6 +8,9 @@ struct IndexPage { // MARK: Properties + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + let assetVersion: String? + /// The locale the page content is localized to. let locale: Locale @@ -16,10 +20,15 @@ struct IndexPage { // MARK: Initializers /// Creates a landing page localized to the given locale. - /// - Parameter locale: the locale the page content is localized to. + /// - Parameters: + /// - locale: the locale the page content is localized to. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the + /// default) to leave them unversioned. init( - locale: Locale + locale: Locale, + assetVersion: String? = nil ) { + self.assetVersion = assetVersion self.locale = locale self.localize = .init(bundle: .module) } @@ -38,12 +47,12 @@ extension IndexPage: Page { } } - var scripts: [StaticFile] { - [.index, .shared] + var scripts: [any Asset] { + [StaticFile.index, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .index] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.index] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift deleted file mode 100644 index e8bc515..0000000 --- a/Services/Website/Sources/Library/Internal/Protocols/Page.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Elementary -import Foundation -import Localization - -/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content. -/// -/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the -/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the -/// document language derived from the locale. -protocol Page: HTMLDocument, Sendable { - - // MARK: Associated types - - /// The type of the page's markup. - associatedtype Content: HTML - - // MARK: Properties - - /// The page's markup, rendered before the ``scripts``. - @HTMLBuilder - var content: Content { get } - - /// The locale the page content is localized to. - var locale: Locale { get } - - /// The scripts loaded at the end of the document body, in order. - var scripts: [StaticFile] { get } - - /// The stylesheets linked in the document head, in order. - var stylesheets: [StaticFile] { get } - -} - -// MARK: - Implementations - -extension Page { - - // MARK: Computed - - /// The page ``content`` followed by its ``scripts``. - @HTMLBuilder - var body: some HTML { - content - for file in scripts { - script(.src(file.urlPath(for: .js))) {} - } - } - - /// The metadata, ``stylesheets``, icon, and manifest links placed in the document head. - /// - /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already - /// emits `` before this markup, and HTML5 allows only one. - @HTMLBuilder - var head: some HTML { - meta( - .name(.viewport), - .content("width=device-width, initial-scale=1") - ) - for file in stylesheets { - link( - .rel(.stylesheet), - .href(file.urlPath(for: .css)) - ) - } - link( - .rel(.icon), - .href(StaticFile.favicon.urlPath(for: .ico)), - .custom( - name: "sizes", - value: "any" - ) - ) - link( - .rel(.icon), - .href(StaticFile.icon.urlPath(for: .svg)), - .custom( - name: "type", - value: "image/svg+xml" - ) - ) - link( - .rel("apple-touch-icon"), - .href(StaticFile.appleTouchIcon.urlPath(for: .png)) - ) - link( - .rel("manifest"), - .href(StaticFile.site.urlPath(for: .webmanifest)) - ) - meta( - .name("theme-color"), - .content("#fafafa") - ) - meta( - .name("theme-color"), - .content("#0c0710"), - .custom( - name: "media", - value: "(prefers-color-scheme: dark)" - ) - ) - } - - /// The document language, derived from the page's locale and falling back to the default language. - var lang: String { - locale.language.languageCode?.identifier - ?? LanguageList(bundle: .module).default - } - -} diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift deleted file mode 100644 index f9da28a..0000000 --- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Elementary -import HTTPTypes -import Hummingbird -import NIOCore - -/// A pre-rendered HTTP response for a fully static HTML page. -/// -/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those -/// bytes — along with a fixed status and precomputed headers — instead of re-rendering. This suits -/// pages whose markup never changes between requests, such as the landing page and the not-found -/// page, avoiding a per-request Elementary render on hot paths. -/// -/// ``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. -struct CachedHTMLResponse: Sendable { - - // MARK: Properties - - /// The page rendered to bytes once. - private let buffer: ByteBuffer - /// The headers applied to every response, precomputed once. - private let headers: HTTPFields - /// The status applied to every response. - private let status: HTTPResponse.Status - - // MARK: Initializers - - /// Renders the given document to bytes once. - /// - Parameters: - /// - status: the status applied to every response. Defaults to `.ok`. - /// - additionalHeaders: extra headers merged onto every response, alongside the content type. - /// Used to carry per-language signals such as `Content-Language` and `Vary`. - /// - document: the static HTML document to render and cache. - init( - status: HTTPResponse.Status = .ok, - additionalHeaders: HTTPFields = [:], - document: some HTMLDocument - ) { - var headers: HTTPFields = [ - .contentType: "text/html; charset=utf-8" - ] - - for field in additionalHeaders { - headers[field.name] = field.value - } - - self.status = status - self.headers = headers - self.buffer = .init(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: headers, - body: .init { [buffer] writer in - try await writer.write(buffer) - try await writer.finish(nil) - } - ) - } - -} diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift similarity index 57% rename from Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift rename to Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 0172f98..36e98d2 100644 --- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -1,26 +1,13 @@ 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 } - -} - -// MARK: - Context +import Infrastructure +import NIOCore /// The website's request context. /// /// Extends the core request storage with the negotiated language, defaulting to the default -/// supported language until ``LocalizationMiddleware`` resolves it from the request. -public struct WebsiteRequestContext: LocalizedRequestContext { +/// supported language until ``LocalizationMiddleware`` resolves it from the request, and with +/// the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. +public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext { // MARK: Properties @@ -28,6 +15,8 @@ public struct WebsiteRequestContext: LocalizedRequestContext { public var coreContext: CoreRequestContextStorage /// The language identifier negotiated for the request. public var language: String + /// The address of the connected client, captured from the source channel. + public let remoteAddress: SocketAddress? // MARK: Initializers @@ -38,6 +27,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext { ) { self.coreContext = .init(source: source) self.language = .empty + self.remoteAddress = source.channel.remoteAddress } } diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index f4995ed..fef2cb9 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,3 +1,4 @@ +import Foundation import Hummingbird import Infrastructure @@ -24,9 +25,16 @@ public struct RootController { // MARK: Initializers /// Creates a root controller. - public init() { - self.responses = .init { - IndexPage(locale: $0) + /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` + /// (the default) to leave them unversioned. + public init( + assetVersion: String? = nil + ) { + self.responses = .init(bundle: .module) { + IndexPage( + locale: $0, + assetVersion: assetVersion + ) } } @@ -70,7 +78,10 @@ private extension RootController { request: Request, context: Context ) -> Response { - responses.response(for: context.language) + responses.response( + for: context.language, + request: request + ) } } diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 9e3ac7d..9e4a82e 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -3,7 +3,9 @@ import Configuration extension AbsoluteConfigKey { /// A namespace for the static files cache configuration keys, as absolute keys. public enum Cache { - /// The absolute configuration key for the max-age, in seconds, applied to text-based static files. + /// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts. + public static let maxAgeAsset: AbsoluteConfigKey = .init(.Cache.maxAgeAsset) + /// The absolute configuration key for the max-age, in seconds, applied to unversioned text-based static files. public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText) /// The absolute configuration key for the max-age, in seconds, applied to image static files. public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage) @@ -50,6 +52,15 @@ extension AbsoluteConfigKey { /// The absolute configuration key for the minimum log level. public static let level: AbsoluteConfigKey = .init(.Log.level) } + /// A namespace for the rate limit configuration keys, as absolute keys. + public enum RateLimit { + /// The absolute configuration key for the number of requests admitted per client per window. + public static let limit: AbsoluteConfigKey = .init(.RateLimit.limit) + /// The absolute configuration key for the window length, in seconds. + public static let window: AbsoluteConfigKey = .init(.RateLimit.window) + /// The absolute configuration key for keying clients by the first `X-Forwarded-For` entry. + public static let trustForwardedFor: AbsoluteConfigKey = .init(.RateLimit.trustForwardedFor) + } /// A namespace for the path configuration keys, as absolute keys. public enum Path { /// The absolute configuration key for the directory the static files are served from. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index cef6a42..289641c 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -3,7 +3,9 @@ import Configuration extension ConfigKey { /// A namespace for the static files cache configuration keys. public enum Cache { - /// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text). + /// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts. + public static let maxAgeAsset: ConfigKey = "cache.maxAge.asset" + /// The configuration key for the max-age, in seconds, applied to unversioned text-based static files (e.g. plain text). public static let maxAgeText: ConfigKey = "cache.maxAge.text" /// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG). public static let maxAgeImage: ConfigKey = "cache.maxAge.image" @@ -50,6 +52,15 @@ extension ConfigKey { /// The configuration key for the minimum log level. public static let level: ConfigKey = "log.level" } + /// A namespace for the rate limit configuration keys. + public enum RateLimit { + /// The configuration key for the number of requests admitted per client per window. + public static let limit: ConfigKey = "rateLimit.limit" + /// The configuration key for the window length, in seconds. + public static let window: ConfigKey = "rateLimit.window" + /// The configuration key for keying clients by the first `X-Forwarded-For` entry (enable only behind a trusted proxy). + public static let trustForwardedFor: ConfigKey = "rateLimit.trustForwardedFor" + } /// A namespace for the path configuration keys. public enum Path { /// The configuration key for the directory the static files are served from. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index 201068a..f1adf3f 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -1,7 +1,9 @@ extension Int { /// A namespace for the cache's default configuration values. public enum Cache { - /// The default max-age, in seconds, applied to text-based static files (1 hour). + /// The default max-age, in seconds, applied to fingerprinted assets and fonts (1 year). + public static let maxAgeAsset = 31_536_000 + /// The default max-age, in seconds, applied to unversioned text-based static files (1 hour). public static let maxAgeText = 3_600 /// The default max-age, in seconds, applied to image static files (1 week). public static let maxAgeImage = 604_800 diff --git a/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift new file mode 100644 index 0000000..009dc7b --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift @@ -0,0 +1,13 @@ +import Foundation +import Infrastructure + +public extension LocalizationMiddleware { + + // MARK: Initializers + + /// Creates a localization middleware that negotiates against the module's String Catalog languages. + init() { + self.init(bundle: .module) + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift new file mode 100644 index 0000000..545dd1f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift @@ -0,0 +1,21 @@ +import Foundation +import Infrastructure + +public extension NotFoundMiddleware { + + // MARK: Initializers + + /// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages. + /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + init( + assetVersion: String? = nil + ) { + self.init(bundle: .module) { + ErrorPage( + locale: $0, + assetVersion: assetVersion + ) + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 23fa620..a70db40 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -23,27 +23,6 @@ extension String { /// The directory, relative to the working directory, that the website's static files are served from. public static let staticResources = "Resources/Static" } - /// A namespace for the security headers' default configuration values. - /// - /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is - /// "sticky" in browsers, so it stays off unless explicitly configured in production. - public enum Security { - /// The default `Content-Security-Policy`. - /// - /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins - /// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing - /// (`frame-ancestors 'none'`). 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). - public static let frameOptions = "DENY" - /// The default `Referrer-Policy`. - public static let referrerPolicy = "strict-origin-when-cross-origin" - /// The default `Permissions-Policy` (denies access to powerful browser features the site does not use). - public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" - } /// A namespace for the server string constants. public enum Server { /// The website server's name. diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 23fe521..70fce3c 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -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( diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 2ca9b20..449238c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -1,3 +1,4 @@ +import Infrastructure import Testing @testable import WebsiteLibrary @@ -8,7 +9,6 @@ struct StaticFileTests { // MARK: Type aliases typealias File = StaticFile - typealias FileExtension = StaticFile.Extension // MARK: Computed tests @@ -18,7 +18,7 @@ struct StaticFileTests { )) func `file extensions`( for file: File, - expects extensions: [FileExtension] + expects extensions: [AssetExtension] ) { #expect(file.fileExtensions == extensions) } @@ -34,81 +34,6 @@ struct StaticFileTests { #expect(file.fileName == fileName) } - @Test(arguments: zip( - Self.extensions, - Self.contentTypes - )) - func `content type`( - for fileExtension: FileExtension, - expects contentType: String - ) { - #expect(fileExtension.contentType == contentType) - } - - @Test(arguments: zip( - Self.extensions, - Self.subdirectories - )) - func `subdirectory`( - for fileExtension: FileExtension, - expects subdirectory: String? - ) { - #expect(fileExtension.subdirectory == subdirectory) - } - - // MARK: Method tests - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `relative path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.relativePath(for: fileExtension) == relativePath) - } - } - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `url path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.urlPath(for: fileExtension) == "/\(relativePath)") - } - } - - @Test(arguments: [ - "", - ".", - "Resources/Static" - ]) - func `path relative to`( - _ basePath: String - ) { - for file in File.allCases { - for fileExtension in file.fileExtensions { - let pathRelativeToBasePath = file.path( - relativeTo: basePath, - for: fileExtension - ) - let relativePath = file.relativePath(for: fileExtension) - - if basePath.isEmpty { - #expect(pathRelativeToBasePath == relativePath) - } else { - #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") - } - } - } - } - // MARK: CaseIterable tests @Test @@ -124,37 +49,7 @@ private extension StaticFileTests { // MARK: Constants - static let extensions: [FileExtension] = [ - .css, - .js, - .png, - .ico, - .svg, - .txt, - .webmanifest, - .xml - ] - static let contentTypes: [String] = [ - "text/css", - "text/javascript", - "image/png", - "image/vnd.microsoft.icon", - "image/svg+xml", - "text/plain", - "application/manifest+json", - "application/xml" - ] - static let subdirectories: [String?] = [ - "css", - "js", - nil, - nil, - nil, - nil, - nil, - nil - ] - static let fileExtensions: [[FileExtension]] = [ + static let fileExtensions: [[AssetExtension]] = [ [.png], [.css, .js], [.ico], @@ -180,18 +75,5 @@ private extension StaticFileTests { "site", "sitemap" ] - static let relativePaths: [[String]] = [ - ["apple-touch-icon.png"], - ["css/error.css", "js/error.js"], - ["favicon.ico"], - ["icon.svg"], - ["icon-192.png"], - ["icon-512.png"], - ["css/index.css", "js/index.js"], - ["robots.txt"], - ["css/shared.css", "js/shared.js"], - ["site.webmanifest"], - ["sitemap.xml"] - ] } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index fc308d2..a98bb33 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -29,4 +29,18 @@ struct IndexPageTests { #expect(html.contains("/js/index.js")) } + @Test + func `renders versioned asset URLs when given a version`() { + let html = IndexPage( + locale: .init(identifier: "en"), + assetVersion: "0123456789abcdef" + ).render() + + #expect(html.contains("/css/shared.css?v=0123456789abcdef")) + #expect(html.contains("/css/index.css?v=0123456789abcdef")) + #expect(html.contains("/js/shared.js?v=0123456789abcdef")) + #expect(html.contains("/js/index.js?v=0123456789abcdef")) + #expect(html.contains("/favicon.ico?v=0123456789abcdef")) + } + } diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index 54c3a4f..b705b7a 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -1,5 +1,6 @@ import Hummingbird import HummingbirdTesting +import Infrastructure import NIOCore import Testing @@ -42,4 +43,117 @@ struct RootControllerTests { } } + @Test + func `serves the landing page with revalidation headers`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let eTag = try #require(response.headers[.eTag]) + + #expect(eTag.hasPrefix(#"W/""#)) + #expect(response.headers[.cacheControl] == "public, no-cache") + } + } + } + + @Test + func `revalidates a matching conditional request with a 304`() async throws { + try await app.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: .get, + headers: [.ifNoneMatch: eTag] + ) { response in + #expect(response.status == .notModified) + #expect(response.headers[.eTag] == eTag) + #expect(response.body.readableBytes == 0) + } + } + } + + @Test + func `serves the full page to a non-matching conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.ifNoneMatch: #"W/"0123456789abcdef""#] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .ok) + #expect(body.contains("Hello world!")) + } + } + } + + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/index.css?v=0123456789abcdef")) + #expect(body.contains("/js/index.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/index.css""#)) + #expect(!body.contains("?v=")) + } + } + } + +} + +// MARK: - Helpers + +private extension RootControllerTests { + + // MARK: Methods + + /// Builds an application whose root controller appends the given version token to the landing + /// page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + } + + router.addRoutes(RootController( + assetVersion: assetVersion + ).routes) + + return Application(router: router) + } + } diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index 9b71671..60c8871 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -4,6 +4,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("LocalizationMiddleware middleware", .tags(.middleware)) diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 3cffe32..22f1f9b 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -3,6 +3,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("NotFoundMiddleware middleware", .tags(.middleware)) @@ -70,11 +72,102 @@ struct NotFoundMiddlewareTests { method: .get ) { response in let body = String(buffer: response.body) - + #expect(response.status == .badRequest) #expect(!body.contains("Page Not Found")) } } } + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/error.css?v=0123456789abcdef")) + #expect(body.contains("/js/shared.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/error.css""#)) + #expect(!body.contains("?v=")) + } + } + } + + @Test + func `serves the error page without revalidation headers`() async throws { + // A `304 Not Modified` only ever stands in for a success, so the error page must not + // invite revalidation with an entity tag or a cache policy. + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.eTag] == nil) + #expect(response.headers[.cacheControl] == nil) + } + } + } + + @Test + func `serves the full error page to a conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.ifNoneMatch: "*"] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(body.contains("Page Not Found")) + } + } + } + +} + +// MARK: - Helpers + +private extension NotFoundMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose not-found middleware appends the given version token to the + /// error page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) + } + + return Application(router: router) + } + } -- 2.54.0 From 088713532836fe053cb44431ce86af5036ce133c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 28 Jul 2026 23:37:33 +0000 Subject: [PATCH 028/117] Fixes for the Localization package (#26) This PR contains the work done to Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/26 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Internal/Protocols/CatalogResolving.swift | 6 +- .../Internal/Types/LanguageRange.swift | 18 +++ .../Internal/Types/StringCatalog.swift | 120 ++++++++++++++-- .../Public/Enumerations/CatalogState.swift | 17 +++ .../Sources/Public/Methods/Localize.swift | 12 +- .../Sources/Public/Methods/Negotiate.swift | 131 +++++++++++++----- .../Sources/Public/Types/LanguageList.swift | 41 +++--- .../Cases/Public/Methods/LocalizeTests.swift | 86 ++++++++++++ .../Cases/Public/Methods/NegotiateTests.swift | 53 ++++++- .../Public/Types/LanguageListTests.swift | 55 ++++++-- .../Tests/Catalogs/Localizable.xcstrings | 6 + .../Tests/Utils/Resolvers/StubCatalog.swift | 23 +++ Services/Website/Package.swift | 1 + .../Sources/App/Extensions/App+Build.swift | 14 +- .../Internal/Extensions/Page+Defaults.swift | 2 +- .../Extensions/LanguageList+Defaults.swift | 13 ++ 16 files changed, 518 insertions(+), 80 deletions(-) create mode 100644 Packages/Localization/Sources/Internal/Types/LanguageRange.swift create mode 100644 Packages/Localization/Sources/Public/Enumerations/CatalogState.swift create mode 100644 Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift diff --git a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift index bb003fd..a0e4dcf 100644 --- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -16,9 +16,13 @@ protocol CatalogResolving: Sendable { // MARK: Properties - /// The source (development) language, used as the final fallback when a locale has no localization. + /// The source (development) language, used as the final fallback when a locale has no localization + /// and as the default language a ``LanguageList`` serves. var sourceLanguage: String { get } + /// The outcome of reading the backing catalog, for consumers to surface at startup. + var state: CatalogState { get } + /// Every language the backend can resolve strings for, including the source language. var languages: Set { get } diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift new file mode 100644 index 0000000..1afaa6b --- /dev/null +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -0,0 +1,18 @@ +/// A single language range parsed from an `Accept-Language` header entry. +/// +/// A range pairs the language tag a client asked for with the `q` weight expressing how much the +/// client prefers it, as RFC 9110 defines them. ``Negotiate`` parses each comma-separated header +/// entry into one of these, then orders the ranges by descending weight so the most preferred tag +/// is matched first. +struct LanguageRange { + + // MARK: Properties + + /// The language tag the client asked for, such as `de` or `de-AT`, or the `*` wildcard. + let tag: String + + /// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when + /// an entry names no weight). + let quality: Double + +} diff --git a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift index 477c33c..5eb7bd0 100644 --- a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift +++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization /// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources. /// @@ -18,6 +19,9 @@ struct StringCatalog: Sendable { /// The resolved entries, keyed by catalog key then by language code. let entries: [String: [String: String]] + /// The outcome of reading the catalog from its bundle. + let state: CatalogState + // MARK: Computed /// Every language the catalog provides a localization for, including the source language. @@ -46,8 +50,9 @@ struct StringCatalog: Sendable { forResource: table, withExtension: .Extension.stringCatalog ) else { - self.sourceLanguage = "en" + self.sourceLanguage = .Default.sourceLanguage self.entries = [:] + self.state = .missing return } @@ -65,9 +70,59 @@ struct StringCatalog: Sendable { (entry.localizations ?? [:]) .compactMapValues { $0.stringUnit?.value } } + self.state = .loaded } catch { - self.sourceLanguage = "en" + self.sourceLanguage = .Default.sourceLanguage self.entries = [:] + self.state = .undecodable + } + } + +} + +// MARK: - Cache + +extension StringCatalog { + + /// A cache key identifying a catalog by its bundle location and table name. + private struct Key: Hashable, Sendable { + let bundle: URL + let table: String + } + + /// The decoded catalogs, keyed by bundle and table. + private static let cache = Mutex<[Key: StringCatalog]>([:]) + + /// Returns the catalog for the given bundle and table, decoding it on first access. + /// + /// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` + /// bound to the same bundle shares one decoded catalog instead of re-reading its JSON. + /// - Parameters: + /// - bundle: the bundle whose resources contain the String Catalog. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. + /// - Returns: the decoded catalog, from the cache when it has been read before. + static func cached( + bundle: Bundle, + table: String = "Localizable" + ) -> StringCatalog { + let key = Key( + bundle: bundle.bundleURL, + table: table + ) + + return cache.withLock { cache in + if let catalog = cache[key] { + return catalog + } + + let catalog = StringCatalog( + bundle: bundle, + table: table + ) + + cache[key] = catalog + + return catalog } } @@ -77,6 +132,9 @@ struct StringCatalog: Sendable { extension StringCatalog: CatalogResolving { + /// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its + /// primary language code (`pt`), then the source language, then the key itself. Tags are matched + /// case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for. func string( for key: String, in locale: Locale @@ -85,15 +143,46 @@ extension StringCatalog: CatalogResolving { return key } - let language = locale - .language - .languageCode? - .identifier - ?? sourceLanguage + for tag in locale.catalogTags { + if let match = byLanguage.first( + where: { $0.key.lowercased() == tag } + ) { + return match.value + } + } - return byLanguage[language] - ?? byLanguage[sourceLanguage] - ?? key + return byLanguage[sourceLanguage] ?? key + } + +} + +// MARK: - Locale+Extensions + +private extension Locale { + + /// The language tags to resolve a catalog entry against, most specific first. + /// + /// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes + /// `pt-br`), followed by its primary language code when the two differ. + var catalogTags: [String] { + var tags: [String] = [] + let identifier = identifier + .replacingOccurrences( + of: String.Separator.underscore, + with: String.Separator.dash + ) + .lowercased() + + if !identifier.isEmpty { + tags.append(identifier) + } + + if let code = language.languageCode?.identifier.lowercased(), + code != identifier { + tags.append(code) + } + + return tags } } @@ -127,7 +216,18 @@ private extension StringCatalog { // MARK: - String+Constants private extension String { + /// The source language assumed when a catalog is missing or cannot be decoded, matching the + /// package's default localization. + enum Default { + static let sourceLanguage = "en" + } + enum Extension { static let stringCatalog = "xcstrings" } + + enum Separator { + static let dash = "-" + static let underscore = "_" + } } diff --git a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift new file mode 100644 index 0000000..2e35e1b --- /dev/null +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -0,0 +1,17 @@ +/// The outcome of reading a String Catalog from its bundle. +/// +/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the +/// language list falls back to the default language — so nothing fails at the call site. This state is +/// the signal a server checks once at startup to warn before visitors ever see raw localization keys. +public enum CatalogState: Equatable, Sendable { + + /// The catalog was found and decoded; its entries are resolvable. + case loaded + + /// No catalog resource exists in the bundle; every lookup returns its key. + case missing + + /// The catalog resource exists but is not valid `.xcstrings` JSON; every lookup returns its key. + case undecodable + +} diff --git a/Packages/Localization/Sources/Public/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift index e2c18a6..b088d27 100644 --- a/Packages/Localization/Sources/Public/Methods/Localize.swift +++ b/Packages/Localization/Sources/Public/Methods/Localize.swift @@ -12,6 +12,16 @@ public struct Localize: Sendable { /// The backend that resolves keys against the catalog. private let resolver: any CatalogResolving + // MARK: Computed + + /// The outcome of reading the bundle's String Catalog. + /// + /// Resolution degrades to returning raw keys rather than failing, so check this once at startup + /// and warn when it is not ``CatalogState/loaded``. + public var catalogState: CatalogState { + resolver.state + } + // MARK: Initializers /// Creates a localizer backed by the String Catalog in the given bundle. @@ -22,7 +32,7 @@ public struct Localize: Sendable { bundle: Bundle, table: String = "Localizable" ) { - self.init(resolver: StringCatalog( + self.init(resolver: StringCatalog.cached( bundle: bundle, table: table )) diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index 878b649..e0604be 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -4,7 +4,8 @@ import Foundation /// /// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a /// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a -/// supported language identifier, falling back to the default language. +/// supported language identifier, honouring the header's `q` weights as RFC 9110 prescribes and +/// falling back to the default language. public struct Negotiate: Sendable { // MARK: Properties @@ -27,10 +28,12 @@ public struct Negotiate: Sendable { /// Picks the best supported language for the given `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. - /// The header is split into its language tags (dropping any `q` weights), then each tag is matched - /// against the supported languages in order of preference — first by an exact match, then by its - /// primary language subtag, so `de-AT` resolves to a supported `de`. When the header is absent or - /// matches nothing, the default language is returned. + /// 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. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( @@ -40,16 +43,20 @@ public struct Negotiate: Sendable { return list.default } - let tags = tags(from: language) + let ranges = ranges(from: language) - guard !tags.isEmpty else { + guard !ranges.isEmpty else { return list.default } let supported = list.all - for tag in tags { - if let match = match(tag: tag, in: supported) { + for range in ranges { + if range.tag == .wildcard { + return list.default + } + + if let match = match(range.tag, in: supported) { return match } } @@ -65,27 +72,69 @@ private extension Negotiate { // MARK: Methods - /// Extracts the ordered language tags from an `Accept-Language` header value. + /// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference. /// - /// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and - /// blank entries are removed. The original order is kept, which mirrors descending preference - /// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly. + /// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by + /// descending weight, entries weighted 0 are dropped as "not acceptable", and equally weighted + /// entries keep the header order. /// - Parameter acceptLanguage: the raw `Accept-Language` header value. - /// - Returns: the ordered, weight-stripped language tags. - func tags( + /// - Returns: the language ranges, most preferred first. + func ranges( from acceptLanguage: String - ) -> [String] { + ) -> [LanguageRange] { acceptLanguage .split(separator: .Separator.comma) - .map { entry in - entry - .split(separator: .Separator.semicolon) - .first - .map(String.init)? - .trimmingCharacters(in: .whitespaces) - ?? .empty + .compactMap(range(from:)) + .filter { $0.quality > 0 } + .enumerated() + .sorted { lhs, rhs in + lhs.element.quality == rhs.element.quality + ? lhs.offset < rhs.offset + : lhs.element.quality > rhs.element.quality } - .filter { !$0.isEmpty } + .map(\.element) + } + + /// Parses a single `Accept-Language` header entry into its ``LanguageRange``. + /// + /// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight. + /// A missing or malformed weight counts as 1, the highest preference, matching a tag sent + /// without one. + /// - Parameter entry: a single comma-separated header entry. + /// - Returns: the entry's language range, or `nil` when it has no language tag. + func range( + from entry: Substring + ) -> LanguageRange? { + let parts = entry.split(separator: .Separator.semicolon) + let tag = parts.first + .map(String.init)? + .trimmingCharacters(in: .whitespaces) + ?? .empty + + guard !tag.isEmpty else { + return nil + } + + let quality = parts + .dropFirst() + .compactMap { parameter -> Double? in + let parameter = parameter + .trimmingCharacters(in: .whitespaces) + .lowercased() + + guard parameter.hasPrefix(.Prefix.quality) else { + return nil + } + + return Double(parameter.dropFirst(String.Prefix.quality.count)) + } + .first + ?? 1 + + return .init( + tag: tag, + quality: quality + ) } /// Finds the supported language that best matches a single `Accept-Language` tag. @@ -97,7 +146,7 @@ private extension Negotiate { /// - supported: the supported language identifiers. /// - Returns: the matching supported language, or `nil` when the tag matches none. func match( - tag: String, + _ tag: String, in supported: [String] ) -> String? { let tag = tag.lowercased() @@ -117,10 +166,32 @@ private extension Negotiate { } +// MARK: - Character+Extensions + +private extension Character { + + // MARK: Constants + + enum Separator { + static let comma: Character = "," + static let dash: Character = "-" + static let semicolon: Character = ";" + } +} + + // MARK: - String+Extensions private extension String { + + // MARK: Constants + static let empty: String = "" + static let wildcard: String = "*" + + enum Prefix { + static let quality = "q=" + } /// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`). var primarySubtag: String { @@ -130,13 +201,3 @@ private extension String { ?? self } } - -// MARK: - Constants - -private extension Character { - enum Separator { - static let comma: Character = "," - static let dash: Character = "-" - static let semicolon: Character = ";" - } -} diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift index eff831a..42a0e84 100644 --- a/Packages/Localization/Sources/Public/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -8,46 +8,47 @@ public struct LanguageList: Sendable { // MARK: Properties - /// The language served when none of the supported languages match a request. - /// - /// Should match the catalog bundle's development localization. - public let `default`: String - /// The backend that reports the available languages. private let resolver: any CatalogResolving // MARK: Initializers /// Creates a language list backed by the given bundle. - /// - Parameters: - /// - bundle: the bundle whose String Catalog defines the available languages. - /// - default: the language served when none of the supported languages match. Defaults to `"en"`. + /// - Parameter bundle: the bundle whose String Catalog defines the available languages. public init( - bundle: Bundle, - `default`: String = "en" + bundle: Bundle ) { - self.init( - resolver: StringCatalog(bundle: bundle), - default: `default` - ) + self.init(resolver: StringCatalog.cached(bundle: bundle)) } /// Creates a language list backed by the given resolver. /// /// The seam for tests and alternative backends; the public API derives languages from a bundled catalog. - /// - Parameters: - /// - resolver: the backend that reports the available languages. - /// - default: the language served when none of the supported languages match. + /// - Parameter resolver: the backend that reports the available languages. init( - resolver: any CatalogResolving, - `default`: String = "en" + resolver: any CatalogResolving ) { self.resolver = resolver - self.`default` = `default` } // MARK: Computed + /// The outcome of reading the bundle's String Catalog. + /// + /// The list degrades to the default language rather than failing, so check this once at startup + /// and warn when it is not ``CatalogState/loaded``. + public var catalogState: CatalogState { + resolver.state + } + + /// The language served when none of the supported languages match a request. + /// + /// Always the catalog's source (development) language, so the default cannot drift from the + /// bundle the list is bound to. + public var `default`: String { + resolver.sourceLanguage + } + /// Every language the bundle's String Catalog provides a localization for. /// /// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language. diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift index 502d226..f071950 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift @@ -32,6 +32,36 @@ struct LocalizeTests { #expect(text == "Hallo") } + @Test + func `resolves a key in a regional locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "pt-BR"), + ) + + #expect(text == "Olá") + } + + @Test + func `falls back to the primary language for an unmatched regional locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "de-AT"), + ) + + #expect(text == "Hallo") + } + + @Test + func `falls back to the source language for an unsupported locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "fr"), + ) + + #expect(text == "Hello") + } + @Test func `falls back to the key for an unknown entry`() { let text = localize( @@ -42,4 +72,60 @@ struct LocalizeTests { #expect(text == "unknown.key") } + @Test + func `falls back to the key for a missing catalog`() { + let localize = Localize(bundle: .main) + + let text = localize( + "test.greeting", + locale: Locale(identifier: "en") + ) + + #expect(text == "test.greeting") + } + + // MARK: Properties tests + + @Test + func `reports a loaded catalog`() { + #expect(localize.catalogState == .loaded) + } + + @Test + func `reports a missing catalog`() { + let localize = Localize(bundle: .main) + + #expect(localize.catalogState == .missing) + } + + // A malformed catalog cannot ship as a test resource — Xcode generates string symbols for every + // bundled `.xcstrings` and fails the build on invalid JSON — so one is staged in a temporary + // directory bundle instead. + @Test + func `reports an undecodable catalog`() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent( + "UndecodableCatalog-\(UUID().uuidString)", + isDirectory: true + ) + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + defer { + try? FileManager.default.removeItem(at: directory) + } + + try Data("not a catalog".utf8).write( + to: directory.appendingPathComponent("Localizable.xcstrings") + ) + + let bundle = try #require(Bundle(url: directory)) + let localize = Localize(bundle: bundle) + + #expect(localize.catalogState == .undecodable) + } + } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index 4ab3160..5bd2680 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -26,6 +26,20 @@ struct NegotiateTests { #expect(language == "de") } + @Test + func `matches a regional catalog language exactly`() { + let language = negotiate(acceptLanguage: "pt-BR") + + #expect(language == "pt-BR") + } + + @Test + func `matches a regional catalog language by its primary subtag`() { + let language = negotiate(acceptLanguage: "pt") + + #expect(language == "pt-BR") + } + @Test func `respects the header order of preference`() { let language = negotiate(acceptLanguage: "de, en") @@ -34,12 +48,47 @@ struct NegotiateTests { } @Test - func `strips quality weights from the language tags`() { - let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9") + func `orders the language tags by descending quality weight`() { + let language = negotiate(acceptLanguage: "en;q=0.5, de;q=0.9") #expect(language == "de") } + @Test + func `treats a tag without a weight as the highest preference`() { + let language = negotiate(acceptLanguage: "en;q=0.9, de") + + #expect(language == "de") + } + + @Test + func `treats a tag with a malformed weight as the highest preference`() { + let language = negotiate(acceptLanguage: "en;q=0.9, de;q=broken") + + #expect(language == "de") + } + + @Test + func `drops language tags weighted as not acceptable`() { + let language = negotiate(acceptLanguage: "de;q=0, en;q=0.8") + + #expect(language == "en") + } + + @Test + func `falls back to the default when every tag is weighted as not acceptable`() { + let language = negotiate(acceptLanguage: "de;q=0, fr;q=0") + + #expect(language == "en") + } + + @Test + func `serves the default language for a wildcard`() { + let language = negotiate(acceptLanguage: "fr;q=0.9, *;q=0.5") + + #expect(language == "en") + } + @Test func `trims whitespace around the language tags`() { let language = negotiate(acceptLanguage: " de , en ") diff --git a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift index 4e144e2..ea4eaa7 100644 --- a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift @@ -20,16 +20,39 @@ struct LanguageListTests { } @Test - func `uses the provided default language`() { + func `follows the catalog's source language`() { let list = LanguageList( - bundle: .module, - default: "de" + resolver: StubCatalog( + sourceLanguage: "de", + languages: ["de", "en"] + ) ) #expect(list.default == "de") } } + @Suite("catalogState") + struct CatalogState { + @Test + func `reports a loaded catalog`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.catalogState == .loaded) + } + + @Test + func `reports a missing catalog`() { + let list = LanguageList( + bundle: .main + ) + + #expect(list.catalogState == .missing) + } + } + @Suite("all") struct All { @Test @@ -45,20 +68,34 @@ struct LanguageListTests { @Test func `excludes the base localization`() { let list = LanguageList( - bundle: .module + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["Base", "de", "en"] + ) ) - #expect(!list.all.contains("Base")) + #expect(list.all == ["de", "en"]) } @Test - func `lists each language only once`() { + func `sorts the languages`() { let list = LanguageList( - bundle: .module + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["fr", "en", "de"] + ) ) - let all = list.all - #expect(all.count == Set(all).count) + #expect(list.all == ["de", "en", "fr"]) + } + + @Test + func `degrades to the default language for a missing catalog`() { + let list = LanguageList( + bundle: .main + ) + + #expect(list.all == ["en"]) } } diff --git a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings index 509d868..7135307 100644 --- a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings +++ b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings @@ -15,6 +15,12 @@ "state" : "translated", "value" : "Hello" } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Olá" + } } } } diff --git a/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift new file mode 100644 index 0000000..1bd3ead --- /dev/null +++ b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift @@ -0,0 +1,23 @@ +import Foundation + +@testable import Localization + +/// A ``CatalogResolving`` backend with fixed languages, for exercising types apart from a bundled catalog. +struct StubCatalog: CatalogResolving { + + // MARK: Properties + + let sourceLanguage: String + let languages: Set + var state: CatalogState = .loaded + + // MARK: Methods + + func string( + for key: String, + in locale: Locale + ) -> String { + key + } + +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 0163147..71e1f67 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -58,6 +58,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "Localization"), .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index d4f66ed..254319f 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -1,6 +1,7 @@ import Configuration import Hummingbird import HummingbirdCompression +import Localization import Logging import Persistence import Infrastructure @@ -9,7 +10,8 @@ import WebsiteLibrary /// Builds the website application. /// /// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles -/// the router, server configuration, and logger. It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts +/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys. +/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts /// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of /// band (so a shared database is never migrated on boot). /// - Parameter reader: the configuration reader the values are read from. @@ -17,10 +19,20 @@ import WebsiteLibrary func application( reader: ConfigReader ) async -> some ApplicationProtocol { + let languages = LanguageList() let logger = logger( serverName: reader.serverName, logLevel: reader.logLevel ) + + // A broken catalog degrades to serving raw localization keys rather than failing, so it is + // only ever visible to visitors — surface it here instead. + if languages.catalogState != .loaded { + let isCatalogMissing = languages.catalogState == .missing + + logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys") + } + let persistence = Service( driver: reader.driver, logger: logger diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift index 71eefba..f852a71 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -11,7 +11,7 @@ extension Page { /// The document language, derived from the page's locale and falling back to the default language. var lang: String { locale.language.languageCode?.identifier - ?? LanguageList(bundle: .module).default + ?? LanguageList().default } /// The icon, manifest, and theme colour metadata shared by every page of the website. diff --git a/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift new file mode 100644 index 0000000..49f9bdc --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift @@ -0,0 +1,13 @@ +import Foundation +import Localization + +public extension LanguageList { + + // MARK: Initializers + + /// Creates a language list backed by the module's String Catalog. + init() { + self.init(bundle: .module) + } + +} -- 2.54.0 From ea00b94841b3dcd04e64c38eaf51ec7d3e2454ec Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 30 Jul 2026 06:33:57 +0000 Subject: [PATCH 029/117] Tweaks and fixes throughout the project (#27) This PR contains the work done to do a little bit of housekeeping pass across all packages and the Website service. To provide further details about the work: * Refreshed the READMEs and source documentation to match the current code; * Tagged every test case consistently across the Infrastructure, Localization, Persistence, and Website test targets; * Removed Website middleware tests now covered by Infrastructure's own suite; * Conformed the `PrepareDB` method to Sendable; * Relaxes the production Compose DATABASE_TLS default from require to prefer; * Added Persistence test verifying the prefer posture falls back to plaintext connections. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/27 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 28 ++- .../Sources/Internal/Types/FNV1aHash.swift | 5 +- .../Builders/RouteCollectionBuilder.swift | 4 +- .../Public/Enumerations/AssetExtension.swift | 3 +- .../RouterMethods+RouteCollections.swift | 6 +- .../Public/Extensions/String+Constants.swift | 11 +- .../Public/Methods/FingerprintAssets.swift | 12 +- .../Middlewares/LocalizationMiddleware.swift | 9 +- .../SecurityHeadersMiddleware.swift | 39 ++-- .../Sources/Public/Protocols/Asset.swift | 13 +- .../Protocols/LocalizedRequestContext.swift | 5 +- .../Sources/Public/Protocols/Page.swift | 4 +- .../Public/Protocols/RouterController.swift | 4 +- .../LocalizedHTMLCollectionResponse.swift | 11 +- .../Cases/Internal/Types/FNV1aHashTests.swift | 5 +- .../Enumerations/AssetExtensionTests.swift | 5 +- .../RouterMethods+RouteCollectionsTests.swift | 5 +- .../Methods/FingerprintAssetsTests.swift | 5 +- .../LocalizationMiddlewareTests.swift | 5 +- .../Middlewares/NotFoundMiddlewareTests.swift | 5 +- .../RateLimitMiddlewareTests.swift | 5 +- .../SecurityHeadersMiddlewareTests.swift | 5 +- .../Middlewares/VaryMiddlewareTests.swift | 5 +- .../Cases/Public/Protocols/AssetTests.swift | 5 +- .../Cases/Public/Protocols/PageTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 10 +- Packages/Localization/README.md | 44 +++++ .../Internal/Protocols/CatalogResolving.swift | 17 +- .../Internal/Types/LanguageRange.swift | 8 +- .../Internal/Types/StringCatalog.swift | 13 +- .../Public/Enumerations/CatalogState.swift | 9 +- .../Sources/Public/Methods/Localize.swift | 12 +- .../Sources/Public/Methods/Negotiate.swift | 29 ++- .../Sources/Public/Types/LanguageList.swift | 6 +- .../Cases/Public/Methods/LocalizeTests.swift | 5 +- .../Cases/Public/Methods/NegotiateTests.swift | 5 +- .../Public/Types/LanguageListTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 8 + Packages/Persistence/Package.swift | 22 ++- Packages/Persistence/README.md | 60 ++++++ .../Sources/Public/Enumerations/Driver.swift | 11 +- .../Sources/Public/Enumerations/TLS.swift | 23 ++- .../Sources/Public/Methods/PrepareDB.swift | 12 +- .../Sources/Public/Methods/Probe.swift | 13 +- .../Sources/Public/Methods/Service.swift | 14 +- .../Sources/Public/Types/Configuration.swift | 3 +- .../Cases/Public/Enumerations/TLSTests.swift | 32 +++- .../Cases/Public/Methods/ProbeTests.swift | 5 +- .../Cases/Public/Methods/ServiceTests.swift | 5 +- .../Utils/Extensions/Tag+Constants.swift | 8 + .../Utils/Fakes/PlaintextMySQLServer.swift | 162 ++++++++++++++++ Services/Website/Dockerfile | 25 ++- Services/Website/README.md | 22 ++- Services/Website/Sources/App/App.swift | 6 +- .../Sources/App/Extensions/App+Build.swift | 15 +- .../Extensions/ConfigReader+Properties.swift | 6 +- .../Internal/Enumerations/StaticFile.swift | 5 +- .../Library/Internal/Pages/IndexPage.swift | 3 +- .../Contexts/WebsiteRequestContext.swift | 5 +- .../Public/Controllers/HealthController.swift | 13 +- .../Public/Controllers/RootController.swift | 12 +- .../Enumerations/StaticFileTests.swift | 5 +- .../Cases/Internal/Pages/ErrorPageTests.swift | 5 +- .../Cases/Internal/Pages/IndexPageTests.swift | 5 +- .../Controllers/HealthControllerTests.swift | 5 +- .../Controllers/RootControllerTests.swift | 5 +- .../LocalizationMiddlewareTests.swift | 57 ------ .../Middlewares/NotFoundMiddlewareTests.swift | 173 ------------------ .../Utils/Extensions/Tag+Constants.swift | 4 +- Services/Website/docker-compose.override.yml | 12 +- Services/Website/docker-compose.yml | 12 +- 71 files changed, 633 insertions(+), 512 deletions(-) create mode 100644 Packages/Localization/README.md create mode 100644 Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Persistence/README.md create mode 100644 Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift delete mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift delete mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 7913a19..cade1d7 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -3,7 +3,6 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too ## Overview The package provides, grouped by role: - | Role | Types | | --- | --- | | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | @@ -11,23 +10,36 @@ The package provides, grouped by role: | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | +| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules The package holds only what every service can reuse; anything a service owns is injected, never referenced: - - **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. ## Layout Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +├── Public/ public API +│ ├── Builders/ RouteCollectionBuilder +│ ├── Enumerations/ AssetExtension +│ ├── Extensions/ addController, plus the default header names and values +│ ├── Methods/ FingerprintAssets +│ ├── Middlewares/ the five HTTP middlewares +│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController +│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +└── Internal/ + └── Types/ implementation details (FNV1aHash) +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux +└── Utils/ stubs (StubAsset, StubPage, …) and the suite Tag constants +``` -``` -Sources/Public// public API (Protocols, Middlewares, Responses, …) -Sources/Internal// implementation details (e.g. FNV1aHash) -Tests/Cases/… mirrors the source layout -Tests/Utils/… stubs and test-only extensions -``` +## Testing +Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift index c33baab..4165586 100644 --- a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -2,9 +2,8 @@ import Foundation /// Hashes bytes with the FNV-1a 64-bit algorithm. /// -/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it -/// suits values that must agree between instances and survive restarts: the asset version token -/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). +/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive +/// restarts: the asset version token (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). /// It is not cryptographic — a collision only risks serving a stale cached asset, not security. struct FNV1aHash { diff --git a/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift index 548bc46..a201c2e 100644 --- a/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift +++ b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift @@ -2,8 +2,8 @@ import Hummingbird /// A result builder that collects the route collections of ``RouterController`` values into a stack. /// -/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting -/// controllers be listed declaratively rather than having their routes added one statement at a time. +/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting controllers be listed declaratively rather than having +/// their routes added one statement at a time. @resultBuilder public enum RouteCollectionBuilder { diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift index 8729209..9d1ae00 100644 --- a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -1,7 +1,6 @@ /// 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. +/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths. public enum AssetExtension: String, Sendable { /// A Cascading Style Sheets file. case css diff --git a/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift index d753351..25c55f2 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift @@ -4,8 +4,7 @@ public extension RouterMethods { // MARK: Methods - /// Adds the routes of ``RouterController`` values to the router using the - /// ``RouteCollectionBuilder`` result builder. + /// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder. /// /// Mirrors `addMiddleware`, letting controllers be listed declaratively: /// @@ -16,8 +15,7 @@ public extension RouterMethods { /// } /// ``` /// - /// Each controller's route collection is added at the router's root, exactly as a - /// sequence of `addRoutes(_:)` calls would. + /// Each controller's route collection is added at the router's root, exactly as a sequence of `addRoutes(_:)` calls would. /// - Parameter build: the controller stack result builder. /// - Returns: the router, so calls can be chained. @discardableResult diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift index d01536e..2791b1e 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -1,15 +1,14 @@ extension String { /// A namespace for the security headers' default configuration values. /// - /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is - /// "sticky" in browsers, so it stays off unless explicitly configured in production. + /// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is "sticky" in browsers, so it stays off unless explicitly + /// configured in production. public enum Security { /// The default `Content-Security-Policy`. /// - /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins - /// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing - /// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link - /// external stylesheets. + /// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document + /// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must + /// link external stylesheets. public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" /// The default `X-Content-Type-Options` (disables MIME sniffing). public static let contentTypeOptions = "nosniff" diff --git a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift index 3a55d87..70a2097 100644 --- a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -28,9 +28,8 @@ public struct FingerprintAssets: Sendable { /// Fingerprints the static files under the given directory. /// - /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its - /// later changes would not bust caches — a warning there usually points at a permissions - /// problem in the deployment. + /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its later changes would not bust caches — a warning there + /// usually points at a permissions problem in the deployment. /// - Parameter path: the directory the static files are served from. /// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned). public func callAsFunction( @@ -42,10 +41,9 @@ public struct FingerprintAssets: Sendable { return nil } - // The path-based enumerator yields paths relative to the directory, so the token depends - // only on the directory's contents — never on where the directory itself lives (the - // URL-based enumerator standardizes symlinked bases, e.g. `/var/…` to `/private/var/…`, - // which would leak the absolute path into the hash). + // The path-based enumerator yields paths relative to the directory, so the token depends only on the + // directory's contents — never on where the directory itself lives (the URL-based enumerator standardizes + // symlinked bases, e.g. `/var/…` to `/private/var/…`, which would leak the absolute path into the hash). var files: [String] = [] while let relativePath = enumerated.nextObject() as? String { diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index c743aa8..964909c 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -5,12 +5,11 @@ 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 `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 request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its +/// content by header. public struct LocalizationMiddleware { // MARK: Properties diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index ffa531f..8658e8c 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -3,13 +3,12 @@ import Hummingbird /// Stamps a set of security-related HTTP headers onto every response. /// -/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever -/// response bubbles back up — the rendered pages, the error page produced by -/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies -/// the strict, hardened interpretation of the content instead of its lenient legacy defaults. +/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up — the rendered pages, the +/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies the strict, hardened +/// interpretation of the content instead of its lenient legacy defaults. /// -/// The headers are precomputed once from the ``Configuration`` at initialization and reused for -/// every request, so the per-request cost is a handful of header copies. +/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of +/// header copies. public struct SecurityHeadersMiddleware { // MARK: Properties @@ -20,9 +19,8 @@ public struct SecurityHeadersMiddleware { // MARK: Initializers /// Creates a security-headers middleware. - /// - Parameter configuration: the headers applied to every response. Defaults to a hardened - /// baseline suitable for a static site, with `Strict-Transport-Security` left off (see - /// ``Configuration``). + /// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with + /// `Strict-Transport-Security` left off (see ``Configuration``). public init( configuration: Configuration = .init() ) { @@ -37,14 +35,11 @@ extension SecurityHeadersMiddleware: RouterMiddleware { // MARK: Functions - /// Passes the request down the chain and stamps the configured security headers onto the - /// response on the way back up. + /// Passes the request down the chain and stamps the configured security headers onto the response on the way back up. /// - /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the - /// controllers) are converted to their response here rather than left to the router: the router - /// converts them above the middleware chain, where the response would escape these headers. - /// Existing values for the same header names are replaced so downstream middleware cannot leave - /// a weaker policy in place. + /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response + /// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers. + /// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. @@ -106,10 +101,9 @@ private extension SecurityHeadersMiddleware.Configuration { extension SecurityHeadersMiddleware { /// The set of security headers a ``SecurityHeadersMiddleware`` applies. /// - /// Each property maps to a single response header. A `nil` value omits that header entirely, - /// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send - /// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be - /// switched on (via configuration) only in TLS-terminated production. + /// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays + /// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on + /// (via configuration) only in TLS-terminated production. public struct Configuration: Sendable { // MARK: Properties @@ -131,9 +125,8 @@ extension SecurityHeadersMiddleware { /// Creates a security-headers configuration. /// - /// Every parameter defaults to the hardened baseline defined in `String.Security`, except - /// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header - /// to drop it from the response. + /// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults + /// to `nil` (omitted). Pass `nil` for any header to drop it from the response. /// - Parameters: /// - contentSecurityPolicy: the `Content-Security-Policy` value. /// - contentTypeOptions: the `X-Content-Type-Options` value. diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift index 6624516..be5506d 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -1,9 +1,7 @@ -/// An asset shipped with a website: a file stored under the static files root and served by -/// Hummingbird's `FileMiddleware` middleware. +/// An asset shipped with a website: a file stored under the static files root and served by Hummingbird's `FileMiddleware` middleware. /// -/// A conforming asset supplies its file name and the extensions it is available with, each -/// resolving to its own file; the protocol derives the paths from them: the file's path within -/// the static files root and the URL path it is served at, optionally versioned to bust caches. +/// A conforming asset supplies its file name and the extensions it is available with, each resolving to its own file; the protocol derives the paths from them: +/// the file's path within the static files root and the URL path it is served at, optionally versioned to bust caches. public protocol Asset: Sendable { // MARK: Properties @@ -58,9 +56,8 @@ public extension Asset { /// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`). /// - /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): - /// `FileMiddleware` ignores the query when resolving the file, while caches key on the full - /// URL, so a deploy that changes the assets busts every cached copy at once. + /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): `FileMiddleware` ignores the query when + /// resolving the file, while caches key on the full URL, so a deploy that changes the assets busts every cached copy at once. /// - Parameters: /// - fileExtension: the extension of the file to resolve. /// - version: the version token to append, or `nil` to leave the URL unversioned. diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift index 86fec3e..50dcb35 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -2,9 +2,8 @@ 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. +/// ``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 diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 1ab393e..7875f6b 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -62,8 +62,8 @@ public extension Page { /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. /// - /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already - /// emits `` before this markup, and HTML5 allows only one. + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, + /// and HTML5 allows only one. @HTMLBuilder var head: some HTML { meta( diff --git a/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift index 819ec82..ae1ca19 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift @@ -2,8 +2,8 @@ import Hummingbird /// A type exposing its endpoints as a route collection ready to be added to a router. /// -/// Conforming controllers group related endpoints behind a single ``routes`` property, -/// so the application composes them declaratively with ``Hummingbird/RouterMethods/addController(_:)``: +/// Conforming controllers group related endpoints behind a single ``routes`` property, so the application composes them declaratively with +/// ``Hummingbird/RouterMethods/addController(_:)``: /// /// ```swift /// struct HealthController: RouterController { diff --git a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index b8c1161..b497105 100644 --- a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -6,10 +6,9 @@ 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. +/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring +/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and +/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone. public struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Properties @@ -54,8 +53,8 @@ public struct LocalizedHTMLCollectionResponse: Sendable { /// - Parameters: /// - language: the negotiated language identifier. /// - request: the request the response answers, consulted for conditional revalidation. - /// - Returns: the cached response for the language, the default language's response when the - /// language is unavailable, or a `500 Internal Server Error` if neither is cached. + /// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a + /// `500 Internal Server Error` if neither is cached. public func response( for language: String, request: Request diff --git a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift index e876252..8931989 100644 --- a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("FNV1aHash type") +@Suite( + "FNV1aHash type", + .tags(.type) +) struct FNV1aHashTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift index 98730b5..f49dbea 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("AssetExtension enumeration") +@Suite( + "AssetExtension enumeration", + .tags(.asset) +) struct AssetExtensionTests { // MARK: Computed tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift index 0bae410..8deb485 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Infrastructure -@Suite("addController method") +@Suite( + "addController method", + .tags(.`extension`) +) struct RouterMethodsTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift index 5c073e8..cb8d213 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift @@ -3,7 +3,10 @@ import Testing @testable import Infrastructure -@Suite("FingerprintAssets method") +@Suite( + "FingerprintAssets method", + .tags(.asset) +) struct FingerprintAssetsTests { // MARK: Properties diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index c18276e..f246f5c 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -7,7 +7,10 @@ import Testing @testable import Infrastructure -@Suite("LocalizationMiddleware middleware", .tags(.middleware)) +@Suite( + "LocalizationMiddleware middleware", + .tags(.middleware) +) struct LocalizationMiddlewareTests { // MARK: Constants diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 895d90c..ea26298 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -6,7 +6,10 @@ import Testing @testable import Infrastructure -@Suite("NotFoundMiddleware middleware", .tags(.middleware)) +@Suite( + "NotFoundMiddleware middleware", + .tags(.middleware) +) struct NotFoundMiddlewareTests { // MARK: Constants diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift index 5c6bd32..75aa6c7 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("RateLimitMiddleware middleware", .tags(.middleware)) +@Suite( + "RateLimitMiddleware middleware", + .tags(.middleware) +) struct RateLimitMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index 78db8c0..c15e01d 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) +@Suite( + "SecurityHeadersMiddleware middleware", + .tags(.middleware) +) struct SecurityHeadersMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift index afa7dad..30389de 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Infrastructure -@Suite("VaryMiddleware middleware", .tags(.middleware)) +@Suite( + "VaryMiddleware middleware", + .tags(.middleware) +) struct VaryMiddlewareTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift index ea9c778..08b392b 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift @@ -2,7 +2,10 @@ import Testing @testable import Infrastructure -@Suite("Asset protocol") +@Suite( + "Asset protocol", + .tags(.`protocol`) +) struct AssetTests { // MARK: Properties diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index a09a533..485d65e 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -4,7 +4,10 @@ import Testing @testable import Infrastructure -@Suite("Page protocol", .tags(.page)) +@Suite( + "Page protocol", + .tags(.`protocol`) +) struct PageTests { // MARK: Functional tests diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift index e83de46..9fec14b 100644 --- a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -1,8 +1,14 @@ import Testing extension Tag { + /// Tests exercising the asset scaffolding of the Infrastructure package. + @Tag static var asset: Tag + /// Tests exercising an extension of the Infrastructure package. + @Tag static var `extension`: Tag /// Tests exercising a middleware of the Infrastructure package. @Tag static var middleware: Tag - /// Tests exercising the page scaffolding of the Infrastructure package. - @Tag static var page: Tag + /// Tests exercising a protocol scaffolding of the Infrastructure package. + @Tag static var `protocol`: Tag + /// Tests exercising an internal type of the Infrastructure package. + @Tag static var type: Tag } diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md new file mode 100644 index 0000000..e0a1c40 --- /dev/null +++ b/Packages/Localization/README.md @@ -0,0 +1,44 @@ +# 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. + +## Overview +The package provides, grouped by role: +| 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 | +| 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`) | + +## Design rules +- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`. Adding a language is a translation-only change — once a locale exists in the catalog, `LanguageList` and `Negotiate` pick it up with no code change. +- **The locale is always explicit.** A server has no single "current" locale, so every lookup names the locale to resolve in; nothing reads process-wide locale state. +- **Raw `.xcstrings` parsing, for Linux parity.** The catalog is decoded from its JSON rather than through Foundation's compiled-catalog APIs, which are unavailable or non-functional on Linux. Consumers must `.copy` the catalog resource verbatim (not `.process` it) so it ships as raw JSON on every platform. Only simple `stringUnit` values are decoded; plural and device variations are not represented. +- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` once at startup and warn when it is not `.loaded`, before visitors ever see raw keys. +- **Method structs.** `Localize` and `Negotiate` hold their lifetime-fixed configuration (the bundle) in `init` and take only per-call inputs in `callAsFunction`. +- **One decoded catalog per bundle.** Catalogs are immutable at runtime, so every `Localize`, `Negotiate`, and `LanguageList` bound to the same bundle and table shares one cached `StringCatalog`. + +## Layout +Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +├── Public/ +│ ├── Enumerations/ CatalogState +│ ├── Methods/ Localize, Negotiate +│ └── Types/ LanguageList +└── Internal/ + ├── Protocols/ CatalogResolving, the seam between the public API and the catalog backend + └── Types/ StringCatalog (the cached .xcstrings decoder), LanguageRange +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux +└── Utils/ the StubCatalog resolver and the suite Tag constants +``` + +## Testing +Every suite carries a tag naming the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Infrastructure` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- No package dependencies — Foundation and Synchronization only. diff --git a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift index a0e4dcf..9a30292 100644 --- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -2,22 +2,19 @@ import Foundation /// A backend that resolves localized strings for an explicit locale and reports the languages it serves. /// -/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored -/// and resolved. The shipping implementation, ``StringCatalog``, reads a raw `.xcstrings` catalog so it -/// behaves identically on Darwin and Linux. A future backend — for example one built on -/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform -/// without changing any caller. +/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored and resolved. The shipping implementation, +/// ``StringCatalog``, reads a raw `.xcstrings` catalog so it behaves identically on Darwin and Linux. A future backend — for example one built on +/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform without changing any caller. /// -/// Resolution never fails: an implementation returns the key itself when it has no localization for it, -/// mirroring Foundation's `String(localized:)`. This is the contract both a dictionary lookup and the -/// native API can honour, since the native API cannot distinguish a missing key from a translation that +/// Resolution never fails: an implementation returns the key itself when it has no localization for it, mirroring Foundation's `String(localized:)`. +/// This is the contract both a dictionary lookup and the native API can honour, since the native API cannot distinguish a missing key from a translation that /// happens to equal the key. protocol CatalogResolving: Sendable { // MARK: Properties - /// The source (development) language, used as the final fallback when a locale has no localization - /// and as the default language a ``LanguageList`` serves. + /// The source (development) language, used as the final fallback when a locale has no localization and as the default language a ``LanguageList`` + /// serves. var sourceLanguage: String { get } /// The outcome of reading the backing catalog, for consumers to surface at startup. diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift index 1afaa6b..b4c203d 100644 --- a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -1,8 +1,7 @@ /// A single language range parsed from an `Accept-Language` header entry. /// -/// A range pairs the language tag a client asked for with the `q` weight expressing how much the -/// client prefers it, as RFC 9110 defines them. ``Negotiate`` parses each comma-separated header -/// entry into one of these, then orders the ranges by descending weight so the most preferred tag +/// A range pairs the language tag a client asked for with the `q` weight expressing how much the client prefers it, as RFC 9110 defines them. +/// ``Negotiate`` parses each comma-separated header entry into one of these, then orders the ranges by descending weight so the most preferred tag /// is matched first. struct LanguageRange { @@ -11,8 +10,7 @@ struct LanguageRange { /// The language tag the client asked for, such as `de` or `de-AT`, or the `*` wildcard. let tag: String - /// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when - /// an entry names no weight). + /// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when an entry names no weight). let quality: Double } diff --git a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift index 5eb7bd0..de68dbc 100644 --- a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift +++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift @@ -95,8 +95,8 @@ extension StringCatalog { /// Returns the catalog for the given bundle and table, decoding it on first access. /// - /// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` - /// bound to the same bundle shares one decoded catalog instead of re-reading its JSON. + /// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` bound to the same bundle shares one + /// decoded catalog instead of re-reading its JSON. /// - Parameters: /// - bundle: the bundle whose resources contain the String Catalog. /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. @@ -132,9 +132,8 @@ extension StringCatalog { extension StringCatalog: CatalogResolving { - /// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its - /// primary language code (`pt`), then the source language, then the key itself. Tags are matched - /// case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for. + /// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its primary language code (`pt`), then the source + /// language, then the key itself. Tags are matched case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for. func string( for key: String, in locale: Locale @@ -162,8 +161,8 @@ private extension Locale { /// The language tags to resolve a catalog entry against, most specific first. /// - /// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes - /// `pt-br`), followed by its primary language code when the two differ. + /// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes `pt-br`), followed by its primary language code when + /// the two differ. var catalogTags: [String] { var tags: [String] = [] let identifier = identifier diff --git a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift index 2e35e1b..56d3ceb 100644 --- a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -1,17 +1,12 @@ /// The outcome of reading a String Catalog from its bundle. /// -/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the -/// language list falls back to the default language — so nothing fails at the call site. This state is -/// the signal a server checks once at startup to warn before visitors ever see raw localization keys. +/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the language list falls back to the default language — so +/// nothing fails at the call site. This state is the signal a server checks once at startup to warn before visitors ever see raw localization keys. public enum CatalogState: Equatable, Sendable { - /// The catalog was found and decoded; its entries are resolvable. case loaded - /// No catalog resource exists in the bundle; every lookup returns its key. case missing - /// The catalog resource exists but is not valid `.xcstrings` JSON; every lookup returns its key. case undecodable - } diff --git a/Packages/Localization/Sources/Public/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift index b088d27..b45e264 100644 --- a/Packages/Localization/Sources/Public/Methods/Localize.swift +++ b/Packages/Localization/Sources/Public/Methods/Localize.swift @@ -2,9 +2,8 @@ import Foundation /// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale. /// -/// A server has no single "current" locale, so each lookup must name the locale to use. An instance -/// is bound to the bundle whose catalog holds the strings, then invoked like a function to resolve a -/// key in a chosen locale. +/// A server has no single "current" locale, so each lookup must name the locale to use. An instance is bound to the bundle whose catalog holds the strings, +/// then invoked like a function to resolve a key in a chosen locale. public struct Localize: Sendable { // MARK: Properties @@ -16,8 +15,7 @@ public struct Localize: Sendable { /// The outcome of reading the bundle's String Catalog. /// - /// Resolution degrades to returning raw keys rather than failing, so check this once at startup - /// and warn when it is not ``CatalogState/loaded``. + /// Resolution degrades to returning raw keys rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``. public var catalogState: CatalogState { resolver.state } @@ -56,8 +54,8 @@ public struct Localize: Sendable { /// - Parameters: /// - key: the String Catalog key to look up. /// - locale: the locale to resolve the key in. - /// - Returns: the localized string for the locale, the source-language string when the locale has no - /// entry, or the key itself when the catalog has no entry for it. + /// - Returns: the localized string for the locale, the source-language string when the locale has no entry, or the key itself when the catalog has no + /// entry for it. public func callAsFunction( _ key: String, locale: Locale diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index e0604be..ee41bed 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -2,10 +2,9 @@ import Foundation /// Negotiates the best supported language for a request from its `Accept-Language` header. /// -/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a -/// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a -/// supported language identifier, honouring the header's `q` weights as RFC 9110 prescribes and -/// falling back to the default language. +/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a function — through +/// ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a supported language identifier, honouring the header's `q` weights as +/// RFC 9110 prescribes and falling back to the default language. public struct Negotiate: Sendable { // MARK: Properties @@ -28,12 +27,10 @@ public struct Negotiate: Sendable { /// Picks the best supported language for the given `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. - /// 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. + /// 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. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( @@ -74,9 +71,8 @@ private extension Negotiate { /// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference. /// - /// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by - /// descending weight, entries weighted 0 are dropped as "not acceptable", and equally weighted - /// entries keep the header order. + /// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by descending weight, entries weighted 0 are dropped + /// as "not acceptable", and equally weighted entries keep the header order. /// - Parameter acceptLanguage: the raw `Accept-Language` header value. /// - Returns: the language ranges, most preferred first. func ranges( @@ -98,8 +94,7 @@ private extension Negotiate { /// Parses a single `Accept-Language` header entry into its ``LanguageRange``. /// /// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight. - /// A missing or malformed weight counts as 1, the highest preference, matching a tag sent - /// without one. + /// A missing or malformed weight counts as 1, the highest preference, matching a tag sent without one. /// - Parameter entry: a single comma-separated header entry. /// - Returns: the entry's language range, or `nil` when it has no language tag. func range( @@ -139,8 +134,8 @@ private extension Negotiate { /// Finds the supported language that best matches a single `Accept-Language` tag. /// - /// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the - /// supported languages' primary subtags, so a regional tag such as `de-AT` resolves to `de`. + /// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the supported languages' primary subtags, so a + /// regional tag such as `de-AT` resolves to `de`. /// - Parameters: /// - tag: a single language tag from the header. /// - supported: the supported language identifiers. diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift index 42a0e84..d3b666c 100644 --- a/Packages/Localization/Sources/Public/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -35,16 +35,14 @@ public struct LanguageList: Sendable { /// The outcome of reading the bundle's String Catalog. /// - /// The list degrades to the default language rather than failing, so check this once at startup - /// and warn when it is not ``CatalogState/loaded``. + /// The list degrades to the default language rather than failing, so check this once at startup and warn when it is not ``CatalogState/loaded``. public var catalogState: CatalogState { resolver.state } /// The language served when none of the supported languages match a request. /// - /// Always the catalog's source (development) language, so the default cannot drift from the - /// bundle the list is bound to. + /// Always the catalog's source (development) language, so the default cannot drift from the bundle the list is bound to. public var `default`: String { resolver.sourceLanguage } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift index f071950..3ef1c67 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift @@ -3,7 +3,10 @@ import Testing @testable import Localization -@Suite("Localize method") +@Suite( + "Localize method", + .tags(.method) +) struct LocalizeTests { // MARK: Constants diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index 5bd2680..f7cb0bc 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -3,7 +3,10 @@ import Testing @testable import Localization -@Suite("Negotiate method") +@Suite( + "Negotiate method", + .tags(.method) +) struct NegotiateTests { // MARK: Constants diff --git a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift index ea4eaa7..0c3829b 100644 --- a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift @@ -3,7 +3,10 @@ import Testing @testable import Localization -@Suite("LanguageList type") +@Suite( + "LanguageList type", + .tags(.type) +) struct LanguageListTests { // MARK: Properties tests diff --git a/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..1c1e8cf --- /dev/null +++ b/Packages/Localization/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,8 @@ +import Testing + +extension Tag { + /// Tests exercising a method of the Localization package. + @Tag static var method: Tag + /// Tests exercising a type of the Localization package. + @Tag static var type: Tag +} diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift index ca63107..5d83c1f 100644 --- a/Packages/Persistence/Package.swift +++ b/Packages/Persistence/Package.swift @@ -32,6 +32,14 @@ let package = Package( url: "https://github.com/vapor/sql-kit.git", from: "3.36.0" ), + .package( + url: "https://github.com/vapor/mysql-nio.git", + from: "1.7.0" + ), + .package( + url: "https://github.com/apple/swift-nio.git", + from: "2.65.0" + ), ], targets: [ .target( @@ -59,7 +67,19 @@ let package = Package( .testTarget( name: "PersistenceTests", dependencies: [ - .byName(name: "Persistence") + .byName(name: "Persistence"), + .product( + name: "MySQLNIO", + package: "mysql-nio" + ), + .product( + name: "NIOCore", + package: "swift-nio" + ), + .product( + name: "NIOPosix", + package: "swift-nio" + ), ], path: "Tests" ), diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md new file mode 100644 index 0000000..571fdac --- /dev/null +++ b/Packages/Persistence/README.md @@ -0,0 +1,60 @@ +# Persistence +The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a MySQL/MariaDB backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe. + +## Overview +The package provides, grouped by role: +| Role | Types | +| --- | --- | +| Backend selection | `Driver` (`mysql` or `inMemory`), `Configuration` (the MySQL/MariaDB connection parameters), `TLS` (the connection's TLS posture) | +| 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` | +| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, 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. See the Website service's `ConfigReader+Properties` for the mapping. +- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use. +- **Migrations are declared once, and append-only.** `PrepareDB` is the single place migrations are registered, in the order they must run; alter the schema by adding a new migration, never by editing one that has already run. Registering does not apply them — the in-memory backend is migrated on startup, while a shared MySQL/MariaDB database is migrated out of band (the executable's migrate-and-exit mode), so multiple booting instances never race. +- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types; repositories map them to `Sendable` value-type snapshots (e.g. `Example`) before returning, and the models themselves stay internal to the package. +- **Readiness never throws.** `Probe` runs a `SELECT 1` — the cheapest statement both backends understand, independent of any schema — and maps every failure to `false`, so callers translate it straight into a readiness response. +- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop so every query reaches the same in-memory database, rather than each pooled connection getting its own private one. +- **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +> **Note:** the `prefer` TLS posture is enforced by the driver itself: a supplied TLS configuration upgrades the connection only when the server advertises TLS, and continues in plaintext otherwise (pinned by a test against a fake server that offers no TLS). `require` currently maps to the same configuration and therefore behaves like `prefer` — the refusal when the server offers no TLS is not yet enforced. + +## Layout +Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +├── Public/ +│ ├── Enumerations/ Driver, TLS +│ ├── Methods/ Service, PrepareDB, Probe +│ └── Types/ Configuration +└── Internal/ + ├── Migrations/ CreateExampleRecord + ├── Models/ ExampleRecord + └── Repositories/ ExampleRepository (returning the Example snapshot) +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the + plaintext-only fake MySQL server, and the suite Tag constants +``` + +## Testing +The suite runs against the in-memory backend by default, so `swift test` needs no database. The MySQL/MariaDB integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`, `MYSQL_TEST_NAME`, `MYSQL_TEST_USERNAME`, and `MYSQL_TEST_PASSWORD`); it reverts its migrations afterwards, so the shared database is left as it was found: +```sh +# in-memory only +swift test +# or +# with the local MariaDB up (make db-mount): +MYSQL_TEST_HOST=127.0.0.1 swift test +``` + +Outside the application's service group, a built `Fluent` service must be shut down explicitly — even on failure — or its connection pool asserts on `deinit`; the suites' `do`/`catch` pattern around `fluent.shutdown()` is the shape to follow. + +Every suite carries a tag naming the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Infrastructure` and `Localization` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- Package dependencies: `hummingbird-fluent`, `fluent-mysql-driver`, `fluent-sqlite-driver`, and `sql-kit`; the test target additionally depends on `mysql-nio` and `swift-nio` for the TLS fallback test. diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift index c52b9ef..60f053b 100644 --- a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift +++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift @@ -1,20 +1,17 @@ /// The persistence backend the service runs against. /// -/// The executable picks a driver at startup and hands it to ``Service``, which registers the -/// matching database as the default one. Repositories resolve that default and stay agnostic -/// of which backend is in use. +/// The executable picks a driver at startup and hands it to ``Service``, which registers the matching database as the default one. Repositories resolve +/// that default and stay agnostic of which backend is in use. public enum Driver: Sendable { /// A MySQL/MariaDB server, reached with the given connection parameters. /// - /// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the - /// connection is opened with. + /// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the connection is opened with. case mysql(Configuration) /// An ephemeral, in-process SQLite database held entirely in memory. /// - /// Nothing is written to disk, and all data is lost when the service stops — intended for - /// local development and tests. + /// Nothing is written to disk, and all data is lost when the service stops — intended for local development and tests. case inMemory } diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift index 1efaa16..be6f492 100644 --- a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift +++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift @@ -2,9 +2,8 @@ import NIOSSL /// The TLS posture used when connecting to the database. /// -/// The executable derives a posture from its `database.tls` configuration and passes it along as -/// part of ``Configuration``; the MySQL driver receives the resulting `TLSConfiguration` through -/// ``tlsConfiguration``. +/// The executable derives a posture from its `database.tls` configuration and passes it along as part of ``Configuration``; the MySQL driver +/// receives the resulting `TLSConfiguration` through ``tlsConfiguration``. public enum TLS: Sendable { /// Connect without TLS, in plaintext. @@ -14,6 +13,9 @@ public enum TLS: Sendable { case prefer /// Connect only over TLS, refusing the connection when the server offers none. + /// + /// - Important: the refusal is not yet enforced — until it is, `require` behaves like ``prefer`` and silently falls back to plaintext when the + /// server offers no TLS. case require } @@ -24,18 +26,15 @@ extension TLS { /// The NIO TLS configuration passed to the MySQL driver for this posture. /// - /// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for - /// ``prefer`` and ``require``. + /// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``. /// - /// - Note: `prefer` and `require` currently map to the same client configuration — both enable TLS. - /// The distinction (fall back to plaintext vs. fail when the server offers no TLS) is not yet - /// enforced here; tighten this mapping if that guarantee becomes required. + /// - Note: the driver gives a supplied configuration ``prefer`` semantics natively — it upgrades to TLS only when the server advertises support, + /// and continues in plaintext otherwise — so `prefer` is fully enforced. `require` maps to the same configuration and therefore currently + /// behaves like ``prefer``: the refusal when the server offers no TLS is not yet enforced. var tlsConfiguration: TLSConfiguration? { switch self { - case .off: - return nil - case .prefer, .require: - return .makeClientConfiguration() + case .off: nil + default: .makeClientConfiguration() } } diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index 43283b3..932216e 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -2,9 +2,9 @@ import HummingbirdFluent /// A registrar declaring every migration against a `Fluent` service. /// -/// Built once around the application's `Fluent` service and called as a function — `await migrate()` — -/// during startup, before the migrations are applied. -public struct PrepareDB { +/// Built once around the application's `Fluent` service and called as a function — `await migrate()` — during startup, before the migrations are +/// applied. +public struct PrepareDB: Sendable { // MARK: Initializers @@ -15,9 +15,9 @@ public struct PrepareDB { /// Registers every migration against the `Fluent` service, in order. /// - /// This is the single place migrations are declared: add each new migration here, in the order it must - /// run (migrations are applied in registration order and are append-only). Registering does not apply - /// them — the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do that. + /// This is the single place migrations are declared: add each new migration here, in the order it must run (migrations are applied in registration order + /// and are append-only). Registering does not apply them — the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do + /// that. public func callAsFunction( for fluent: Fluent ) async { diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift index cdbda36..8abbc8b 100644 --- a/Packages/Persistence/Sources/Public/Methods/Probe.swift +++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift @@ -3,8 +3,8 @@ import SQLKit /// A readiness probe reporting whether the database behind a `Fluent` service is reachable. /// -/// Built once around the application's `Fluent` service and called as a function whenever a fresh -/// answer is needed — typically from a readiness endpoint: `let ready = await probe()`. +/// Built once around the application's `Fluent` service and called as a function whenever a fresh answer is needed — typically from a readiness endpoint: +/// `let ready = await probe()`. public struct Probe: Sendable { // MARK: Properties @@ -26,11 +26,10 @@ public struct Probe: Sendable { /// Reports whether the database behind the `Fluent` service is reachable. /// - /// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the MySQL/MariaDB - /// and SQLite backends understand — so a readiness check does not depend on any particular schema or model. - /// Any failure (connection refused, authentication error, pool exhausted) is reported as not reachable - /// rather than thrown, so callers can map it straight onto a readiness response. A default database that - /// is not an SQL database is likewise reported as not reachable. + /// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the MySQL/MariaDB and SQLite backends understand — so + /// a readiness check does not depend on any particular schema or model. Any failure (connection refused, authentication error, pool exhausted) is + /// reported as not reachable rather than thrown, so callers can map it straight onto a readiness response. A default database that is not an SQL + /// database is likewise reported as not reachable. /// - Returns: `true` when the database answers the probe, `false` otherwise. public func callAsFunction() async -> Bool { guard let database = fluent.db() as? any SQLDatabase else { diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift index 033adcf..a8953f2 100644 --- a/Packages/Persistence/Sources/Public/Methods/Service.swift +++ b/Packages/Persistence/Sources/Public/Methods/Service.swift @@ -5,8 +5,7 @@ import Logging /// A factory building the `Fluent` service the application persists through. /// -/// Built once around the driver the executable picks at startup and called as a function to produce -/// the configured service: `let fluent = service()`. +/// Built once around the driver the executable picks at startup and called as a function to produce the configured service: `let fluent = service()`. public struct Service: Sendable { // MARK: Properties @@ -35,10 +34,9 @@ public struct Service: Sendable { /// Builds a `Fluent` service configured for the driver. /// - /// The selected backend is registered as the *default* database, so repositories resolve it with a plain - /// `fluent.db()` and stay agnostic of which driver is in use. The returned service is not yet running; add - /// it to the application's service group (`app.addServices(_:)`) so it starts and shuts its connection pool - /// down alongside the server. + /// The selected backend is registered as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which + /// driver is in use. The returned service is not yet running; add it to the application's service group (`app.addServices(_:)`) so it starts and shuts + /// its connection pool down alongside the server. /// - Returns: the configured `Fluent` service, ready to be added to the service group. public func callAsFunction() -> Fluent { let fluent = Fluent( @@ -63,8 +61,8 @@ public struct Service: Sendable { isDefault: true ) case .inMemory: - // A single connection keeps every query pointed at the same in-memory store, - // rather than each pooled connection getting its own private database. + // A single connection keeps every query pointed at the same in-memory store, rather than each pooled + // connection getting its own private database. fluent.databases.use( .sqlite(.memory, maxConnectionsPerEventLoop: 1), as: .sqlite, diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift index a60408e..8867228 100644 --- a/Packages/Persistence/Sources/Public/Types/Configuration.swift +++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift @@ -1,7 +1,6 @@ /// The connection parameters for the MySQL/MariaDB backend. /// -/// The executable builds this from its `database.*` configuration; the package itself reads no -/// configuration, so these values arrive as plain data. +/// The executable builds this from its `database.*` configuration; the package itself reads no configuration, so these values arrive as plain data. public struct Configuration: Sendable { // MARK: Properties diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift index aa12a00..0cd7d4e 100644 --- a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift @@ -1,9 +1,16 @@ +import Logging +import MySQLNIO +import NIOCore +import NIOPosix import NIOSSL import Testing @testable import Persistence -@Suite("TLS enumeration") +@Suite( + "TLS enumeration", + .tags(.enumeration) +) struct TLSTests { // MARK: Properties tests @@ -25,4 +32,27 @@ struct TLSTests { #expect(configuration.bestEffortEquals(.makeClientConfiguration())) } + @Test + func `prefer falls back to plaintext when the server offers no TLS`() async throws { + // The fake server never advertises `CLIENT_SSL`, so this connection can only succeed by downgrading to + // plaintext — pinning the driver behavior the `prefer` posture relies on. + let server = try await PlaintextMySQLServer.start() + let tlsConfiguration = try #require(TLS.prefer.tlsConfiguration) + let connection = try await MySQLConnection.connect( + to: .init(ipAddress: "127.0.0.1", port: server.port), + username: "loud", + database: "loud", + tlsConfiguration: tlsConfiguration, + logger: Logger(label: "test"), + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + + let isConnected = !connection.isClosed + + try await connection.close().get() + try await server.stop() + + #expect(isConnected) + } + } diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift index 4458f0e..6f44afa 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Persistence -@Suite("Probe method") +@Suite( + "Probe method", + .tags(.method) +) struct ProbeTests { // MARK: Methods tests diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift index 0ef5fb1..b52dbf9 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -5,7 +5,10 @@ import Testing @testable import Persistence -@Suite("Service method") +@Suite( + "Service method", + .tags(.method) +) struct ServiceTests { // MARK: Methods tests diff --git a/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..bec1ff6 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,8 @@ +import Testing + +extension Tag { + /// Tests exercising an enumeration of the Persistence package. + @Tag static var enumeration: Tag + /// Tests exercising a method of the Persistence package. + @Tag static var method: Tag +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift new file mode 100644 index 0000000..27973c7 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift @@ -0,0 +1,162 @@ +import NIOCore +import NIOPosix + +/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake. +/// +/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and +/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only +/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver +/// downgrades to plaintext rather than refusing the connection. +final class PlaintextMySQLServer { + + // MARK: Properties + + /// The port the server listens on, assigned by the system at bind time. + let port: Int + + /// The listening channel the server accepts connections through. + private let channel: Channel + + // MARK: Initializers + + private init( + channel: Channel, + port: Int + ) { + self.channel = channel + self.port = port + } + + // MARK: Functions + + /// Starts a server on the loopback interface, on a system-assigned port. + /// - Returns: the running server, ready to be connected to at ``port``. + static func start() async throws -> PlaintextMySQLServer { + let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(Handler()) + } + } + .bind(host: "127.0.0.1", port: 0) + .get() + + guard let port = channel.localAddress?.port else { + throw ChannelError.unknownLocalAddress + } + + return .init( + channel: channel, + port: port + ) + } + + /// Stops the server, closing its listening channel. + func stop() async throws { + try await channel.close().get() + } + +} + +// MARK: - Handlers + +private extension PlaintextMySQLServer { + + /// Greets a freshly accepted connection, accepts whatever authentication response arrives, + /// and closes on anything after that (e.g. a `COM_QUIT`). + final class Handler: ChannelInboundHandler { + + // MARK: Type aliases + + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + // MARK: Properties + + /// Whether the client's handshake response has already been answered with an OK packet. + private var didAuthenticate = false + + // MARK: Functions + + func channelActive(context: ChannelHandlerContext) { + context.writeAndFlush( + wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)), + promise: nil + ) + } + + func channelRead( + context: ChannelHandlerContext, + data: NIOAny + ) { + guard didAuthenticate else { + didAuthenticate = true + + context.writeAndFlush( + wrapOutboundOut(Self.ok(allocator: context.channel.allocator)), + promise: nil + ) + + return + } + + context.close(promise: nil) + } + + // MARK: Helpers + + /// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet. + /// + /// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`, + /// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`, + /// so the client cannot upgrade the connection to TLS. + private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer { + var payload = allocator.buffer(capacity: 80) + + payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version + payload.writeNullTerminatedString("8.0.0") // server version + payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id + payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1 + payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler + payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION + payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8) + payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit) + payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH + payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length + payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved + payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2 + payload.writeNullTerminatedString("mysql_native_password") // auth plugin name + + return framed(payload, sequence: 0, allocator: allocator) + } + + /// A bare OK packet, framed as the reply to the client's handshake response. + private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer { + var payload = allocator.buffer(capacity: 8) + + payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings + + return framed(payload, sequence: 2, allocator: allocator) + } + + /// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte. + private static func framed( + _ payload: ByteBuffer, + sequence: UInt8, + allocator: ByteBufferAllocator + ) -> ByteBuffer { + var packet = allocator.buffer(capacity: payload.readableBytes + 4) + var payload = payload + + packet.writeInteger(UInt8(payload.readableBytes & 0xff)) + packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff)) + packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff)) + packet.writeInteger(sequence) + packet.writeBuffer(&payload) + + return packet + } + + } + +} diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 2570487..9693882 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -8,14 +8,13 @@ ARG OXIPNG_VERSION=9.1.5 ARG SVGO_VERSION=4.0.2 # Install the minifiers in their own layer, so they are cached across asset changes. -# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do -# not break the build when the base image advances. +# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base +# image advances. RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \ && npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}" -# Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress -# the PNG images in place, keeping their names so the URL paths derived from the -# StaticFile enumeration stay unchanged. +# Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress the PNG images in place, keeping +# their names so the URL paths derived from the StaticFile enumeration stay unchanged. WORKDIR /static COPY ./Services/Website/Resources/Static . RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \ @@ -23,8 +22,8 @@ RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \ && oxipng --opt max --strip safe *.png \ && svgo --recursive --folder . -# Export stage: `docker build --target assets-export --output ` writes the -# minified static files to for local inspection. +# Export stage: `docker build --target assets-export --output ` writes the minified static files to for +# local inspection. FROM scratch AS assets-export COPY --from=assets /static / @@ -44,17 +43,16 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ WORKDIR /build # First just resolve dependencies. -# This creates a cached layer that can be reused as long as the manifests do -# not change. The Website package depends on the local Localization package via -# a relative path, so its manifest must be present for resolution to succeed. +# This creates a cached layer that can be reused as long as the manifests do not change. The Website package depends on +# the local Localization package via a relative path, so its manifest must be present for resolution to succeed. COPY ./Packages/Localization/Package.swift ./Packages/Localization/ COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/ COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve -# Copy only the Swift inputs needed for a release build. Static assets are built -# in the assets stage and copied into staging after the binary is produced. +# Copy only the Swift inputs needed for a release build. Static assets are built in the assets stage and copied into +# staging after the binary is produced. COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources @@ -79,8 +77,7 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; -# Create the static files directory (served by FileMiddleware) and fill it with -# the minified copies from the assets stage +# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage RUN mkdir -p ./Resources/Static COPY --from=assets /static ./Resources/Static diff --git a/Services/Website/README.md b/Services/Website/README.md index 9eef3ae..ec2eaaa 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -34,13 +34,14 @@ The persistence backend runs as a `Fluent` service inside the application's Serv 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) - → LocalizationMiddleware (negotiates the request's language) - → NotFoundMiddleware (renders the localized 404 page on .notFound) - → FileMiddleware (serves Resources/Static) -RootController (GET / → landing page) -HealthController (GET /health → liveness, GET /health/ready → readiness) + → SecurityHeadersMiddleware (security headers on every response) + → VaryMiddleware (marks every response as varying on Accept-Encoding) + → ResponseCompressionMiddleware (gzip/deflate above the size threshold) + → LocalizationMiddleware (negotiates the request's language) + → NotFoundMiddleware (renders the localized 404 page on .notFound) + → FileMiddleware (serves Resources/Static) +RootController (GET / → landing page) +HealthController (GET /health → liveness, GET /health/ready → readiness) ``` ## Configuration @@ -108,6 +109,7 @@ See [Persistence](#persistence-1) below for the workflow. | `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. | ### Rate limiting +These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships. | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. | @@ -182,7 +184,7 @@ 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 the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `WebTests`, `PersistenceTests`, and `LocalizationTests`. +Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, and `LocalizationTests`. The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database: ```sh @@ -259,8 +261,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment | `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`). | -| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. | +| `DATABASE_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. | | `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. | -| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). | +| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). | Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`. diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index 679beff..d75a38f 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -31,9 +31,9 @@ struct App { ] ) - // Migrate-and-exit mode runs the registered migrations against the configured backend and returns, - // so a shared database is migrated by a single deliberate invocation (`--database-migrate`) rather - // than by every booting instance. + // Migrate-and-exit mode runs the registered migrations against the configured backend and returns, so a shared + // database is migrated by a single deliberate invocation (`--database-migrate`) rather than by every booting + // instance. guard !reader.migrate else { try await migration( reader: reader diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 254319f..2164f29 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -25,8 +25,8 @@ func application( logLevel: reader.logLevel ) - // A broken catalog degrades to serving raw localization keys rather than failing, so it is - // only ever visible to visitors — surface it here instead. + // A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to + // visitors — surface it here instead. if languages.catalogState != .loaded { let isCatalogMissing = languages.catalogState == .missing @@ -63,8 +63,8 @@ func application( app.addServices(fluent) - // The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB - // backend is left untouched here: a shared database is migrated out of band to avoid multi-instance races. + // The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB backend is + // left untouched here: a shared database is migrated out of band to avoid multi-instance races. if case .inMemory = reader.driver { app.beforeServerStarts { try await fluent.migrate() @@ -137,8 +137,7 @@ private func logger( /// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's /// language from its `Accept-Language` header, 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, the `SubscriptionController` routes that register newsletter subscriptions, and the `HealthController` routes -/// that serve the health check. +/// 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 served static files. @@ -162,8 +161,8 @@ private func router( logLevel: Logger.Level, probe: Probe ) -> Router { - // HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing - // with HEAD requests get the page's status and headers instead of a 404. + // HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get + // the page's status and headers instead of a 404. let router = Router( context: AppRequestContext.self, options: .autoGenerateHeadEndpoints diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 9acbbe5..c596d3f 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -123,9 +123,9 @@ package extension ConfigReader { /// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys. /// - /// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When - /// `rateLimit.trustForwardedFor` is set, clients are keyed by the first `X-Forwarded-For` entry — - /// enable it only behind a reverse proxy that sets the header, since clients can forge it otherwise. + /// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set, + /// clients are keyed by the first `X-Forwarded-For` entry — enable it only behind a reverse proxy that sets the header, since clients can forge it + /// otherwise. var rateLimit: RateLimitMiddleware.Configuration { .init( limit: int( diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index d953601..3196f8f 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -2,9 +2,8 @@ import Infrastructure /// A static file shipped with the website service. /// -/// Each case identifies a file name stored under the static files root (the `Resources/Static` -/// directory) and served by Hummingbird's `FileMiddleware` middleware. A name can be available -/// with more than one extension (see ``fileExtensions``), each resolving to its own file. +/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's +/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file. enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index bcb0475..843d85a 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -22,8 +22,7 @@ struct IndexPage { /// Creates a landing page localized to the given locale. /// - Parameters: /// - locale: the locale the page content is localized to. - /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the - /// default) to leave them unversioned. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. init( locale: Locale, assetVersion: String? = nil diff --git a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 36e98d2..5c8f037 100644 --- a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -4,9 +4,8 @@ import NIOCore /// The website's request context. /// -/// Extends the core request storage with the negotiated language, defaulting to the default -/// supported language until ``LocalizationMiddleware`` resolves it from the request, and with -/// the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. +/// Extends the core request storage with the negotiated language, defaulting to the default supported language until ``LocalizationMiddleware`` +/// resolves it from the request, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext { // MARK: Properties diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index 4597e27..b6043a0 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -28,8 +28,7 @@ public struct HealthController { // MARK: Initializers /// Creates a health controller. - /// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness - /// route is served. + /// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness route is served. public init( probe: Probe? = nil ) { @@ -72,9 +71,8 @@ private extension HealthController { /// Handles a request for the liveness 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. It reports only that the process is up, - /// with no dependency check, so an orchestrator restarts the process only when the process itself is + /// 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. It reports only that the process is up, with no dependency check, so an orchestrator restarts the process only when the process itself is /// unresponsive. /// - Parameters: /// - request: the incoming request. @@ -93,9 +91,8 @@ private extension HealthController { /// Handles a request for the readiness check. /// - /// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's - /// database is reachable, or `503 Service Unavailable` otherwise, so a load balancer withholds - /// traffic from an instance that cannot yet serve it without restarting the process. + /// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's database is reachable, or `503 Service Unavailable` + /// otherwise, so a load balancer withholds traffic from an instance that cannot yet serve it without restarting the process. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index fef2cb9..b1a440f 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -4,8 +4,7 @@ import Infrastructure /// Serves the website's root routes. /// -/// The controller exposes its routes through its `RouterController` conformance, so the -/// application that composes it registers them declaratively: +/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively: /// /// ```swift /// router.addController { @@ -13,8 +12,7 @@ import Infrastructure /// } /// ``` /// -/// - Note: `Context` is the request context the routes are resolved against, and must match the -/// context of the router the routes are added to. +/// - 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 { // MARK: Properties @@ -25,8 +23,7 @@ public struct RootController { // MARK: Initializers /// Creates a root controller. - /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` - /// (the default) to leave them unversioned. + /// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. public init( assetVersion: String? = nil ) { @@ -67,8 +64,7 @@ private extension RootController { /// Handles a request for the landing page. /// - /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, - /// falling back to the default language. + /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, falling back to the default language. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 449238c..0c141e5 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -3,7 +3,10 @@ import Testing @testable import WebsiteLibrary -@Suite("StaticFile enumeration") +@Suite( + "StaticFile enumeration", + .tags(.enumeration) +) struct StaticFileTests { // MARK: Type aliases diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index 3268b64..4a2771c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -4,7 +4,10 @@ import Testing @testable import WebsiteLibrary -@Suite("ErrorPage page", .tags(.page)) +@Suite( + "ErrorPage page", + .tags(.page) +) struct ErrorPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index a98bb33..d06606a 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -4,7 +4,10 @@ import Testing @testable import WebsiteLibrary -@Suite("IndexPage page", .tags(.page)) +@Suite( + "IndexPage page", + .tags(.page) +) struct IndexPageTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift index 9d565bf..dcf3654 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift @@ -7,7 +7,10 @@ import Testing @testable import WebsiteLibrary -@Suite("HealthController controller", .tags(.controller)) +@Suite( + "HealthController controller", + .tags(.controller) +) struct HealthControllerTests { // MARK: Functional tests diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index b705b7a..a211e9b 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -6,7 +6,10 @@ import Testing @testable import WebsiteLibrary -@Suite("RootController controller", .tags(.controller)) +@Suite( + "RootController controller", + .tags(.controller) +) struct RootControllerTests { // MARK: Constants diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift deleted file mode 100644 index 60c8871..0000000 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import HTTPTypes -import Hummingbird -import HummingbirdTesting -import NIOCore -import Testing - -import Infrastructure - -@testable import WebsiteLibrary - -@Suite("LocalizationMiddleware middleware", .tags(.middleware)) -struct LocalizationMiddlewareTests { - - // MARK: Constants - - private let app: Application = .init(router: { - let router = Router(context: WebsiteRequestContext.self) - - router.addMiddleware { - LocalizationMiddleware() - } - - router.get("language") { _, context in - context.language - } - - return router - }()) - - // MARK: Functional tests - - @Test - func `negotiates a supported language from the header`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/language", - method: .get, - headers: [.acceptLanguage: "en-US,en;q=0.9"] - ) { response in - #expect(String(buffer: response.body) == "en") - } - } - } - - @Test - func `falls back to the default without a header`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/language", - method: .get - ) { response in - #expect(String(buffer: response.body) == "en") - } - } - } - -} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift deleted file mode 100644 index 22f1f9b..0000000 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Hummingbird -import HummingbirdTesting -import NIOCore -import Testing - -import Infrastructure - -@testable import WebsiteLibrary - -@Suite("NotFoundMiddleware middleware", .tags(.middleware)) -struct NotFoundMiddlewareTests { - - // MARK: Constants - - private let app: Application = .init(router: { - let router = Router(context: WebsiteRequestContext.self) - - router.addMiddleware { - LocalizationMiddleware() - NotFoundMiddleware() - } - - router.get("hello") { _, _ in - "Hello!" - } - - router.get("boom") { _, _ -> String in - throw HTTPError(.badRequest) - } - - return router - }()) - - // MARK: Functional tests - - @Test - func `renders the error page for an unmatched request`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/this-path-does-not-exist", - method: .get - ) { response in - let body = String(buffer: response.body) - - #expect(response.status == .notFound) - #expect(response.headers[.contentType] == "text/html; charset=utf-8") - #expect(response.headers[.contentLanguage] == "en") - #expect(response.headers[.vary] == "Accept-Language") - #expect(body.contains("Page Not Found")) - } - } - } - - @Test - func `passes a matched response through untouched`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/hello", - method: .get - ) { response in - #expect(response.status == .ok) - #expect(response.body == ByteBuffer(string: "Hello!")) - } - } - } - - @Test - func `rethrows a non-not-found error unchanged`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/boom", - method: .get - ) { response in - let body = String(buffer: response.body) - - #expect(response.status == .badRequest) - #expect(!body.contains("Page Not Found")) - } - } - } - - @Test - func `renders versioned asset URLs when given a version`() async throws { - try await app( - assetVersion: "0123456789abcdef" - ).test(.router) { client in - try await client.execute( - uri: "/this-path-does-not-exist", - method: .get - ) { response in - let body = String(buffer: response.body) - - #expect(body.contains("/css/error.css?v=0123456789abcdef")) - #expect(body.contains("/js/shared.js?v=0123456789abcdef")) - } - } - } - - @Test - func `renders unversioned asset URLs by default`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/this-path-does-not-exist", - method: .get - ) { response in - let body = String(buffer: response.body) - - #expect(body.contains(#"href="/css/error.css""#)) - #expect(!body.contains("?v=")) - } - } - } - - @Test - func `serves the error page without revalidation headers`() async throws { - // A `304 Not Modified` only ever stands in for a success, so the error page must not - // invite revalidation with an entity tag or a cache policy. - try await app.test(.router) { client in - try await client.execute( - uri: "/this-path-does-not-exist", - method: .get - ) { response in - #expect(response.status == .notFound) - #expect(response.headers[.eTag] == nil) - #expect(response.headers[.cacheControl] == nil) - } - } - } - - @Test - func `serves the full error page to a conditional request`() async throws { - try await app.test(.router) { client in - try await client.execute( - uri: "/this-path-does-not-exist", - method: .get, - headers: [.ifNoneMatch: "*"] - ) { response in - let body = String(buffer: response.body) - - #expect(response.status == .notFound) - #expect(body.contains("Page Not Found")) - } - } - } - -} - -// MARK: - Helpers - -private extension NotFoundMiddlewareTests { - - // MARK: Methods - - /// Builds an application whose not-found middleware appends the given version token to the - /// error page's asset URLs. - /// - Parameter assetVersion: the version token appended to the page's asset URLs. - /// - Returns: the configured application. - func app( - assetVersion: String? - ) -> some ApplicationProtocol { - let router = Router(context: WebsiteRequestContext.self) - - router.addMiddleware { - LocalizationMiddleware() - NotFoundMiddleware( - assetVersion: assetVersion - ) - } - - return Application(router: router) - } - -} diff --git a/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift index 2dee5a2..63cb88f 100644 --- a/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift +++ b/Services/Website/Tests/Library/Utils/Extensions/Tag+Constants.swift @@ -3,8 +3,8 @@ import Testing extension Tag { /// Tests exercising a controller of the Website library. @Tag static var controller: Tag - /// Tests exercising a middleware of the Website library. - @Tag static var middleware: Tag + /// Tests exercising an enumeration of the Website library. + @Tag static var enumeration: Tag /// Tests exercising a page of the Website library. @Tag static var page: Tag } diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index c297e23..4ca579d 100644 --- a/Services/Website/docker-compose.override.yml +++ b/Services/Website/docker-compose.override.yml @@ -1,12 +1,12 @@ # Local development overrides. -# Compose merges this file on top of docker-compose.yml automatically, so a -# plain `docker compose up` builds from source instead of pulling a registry image: +# Compose merges this file on top of docker-compose.yml automatically, so a plain `docker compose up` builds from +# source instead of pulling a registry image: # # docker compose up --build # build locally and run # docker compose up -d # reuse the last local build # -# It reuses the `image:` name from the base file, so the local build is tagged -# the same way the production image would be. +# It reuses the `image:` name from the base file, so the local build is tagged the same way the production image would +# be. services: website: image: ${IMAGE_NAME}:${IMAGE_TAG:-latest} @@ -20,8 +20,8 @@ services: DATABASE_HOST: ${DATABASE_HOST:-localhost} DATABASE_TLS: ${DATABASE_TLS:-off} - # Local development database, started only with the `database` profile so a plain - # `docker compose up` still runs the in-memory backend: + # Local development database, started only with the `database` profile so a plain `docker compose up` still runs the + # in-memory backend: # # docker compose --profile database up mariadb mariadb: diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index 0aa0ffb..06c973c 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -6,9 +6,8 @@ name: loud-platform # docker compose -f docker-compose.yml pull # docker compose -f docker-compose.yml up -d # -# The `-f docker-compose.yml` flag is important in production: it skips the -# docker-compose.override.yml file, which Compose would otherwise merge in -# automatically for local development. +# The `-f docker-compose.yml` flag is important in production: it skips the docker-compose.override.yml file, which +# Compose would otherwise merge in automatically for local development. services: website: image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest} @@ -21,16 +20,15 @@ services: LOG_LEVEL: ${LOG_LEVEL:-info} HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite} SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}" - # Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a - # managed MySQL/MariaDB database. Provide the password via the environment or a - # secret — never commit it. + # Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a managed MySQL/MariaDB database. + # Provide the password via the environment or a secret — never commit it. DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql} DATABASE_HOST: ${DATABASE_HOST:-localhost} DATABASE_PORT: ${DATABASE_PORT:-3306} DATABASE_NAME: ${DATABASE_NAME:-loud-ams} DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams} DATABASE_PASSWORD: ${DATABASE_PASSWORD:-} - DATABASE_TLS: ${DATABASE_TLS:-require} + DATABASE_TLS: ${DATABASE_TLS:-prefer} healthcheck: test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"] interval: 30s -- 2.54.0 From efc933d5d0c3cbba056899645c5a796a1a569984 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 10:57:29 +0000 Subject: [PATCH 030/117] Extra tags and Social Card integration in the Infrastructure package (#28) This PR contains the work done to extends the `Page` protocol with the head tags that control search snippets and link previews: a meta description, a canonical URL, and a social card rendered as _Open Graph_ and _Twitter_ meta tags. All three are optional with nil defaults, so existing conformers compile and render unchanged. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/28 Co-authored-by: Javier Cicchelli --- .../Sources/Public/Protocols/Page.swift | 71 +++++++-- .../Sources/Public/Types/SocialCard.swift | 138 ++++++++++++++++++ .../Types/SocialCard/SocialCardImage.swift | 72 +++++++++ .../Types/SocialCard/SocialCardTag.swift | 55 +++++++ .../Cases/Public/Protocols/PageTests.swift | 50 +++++++ .../Cases/Public/Types/SocialCardTests.swift | 84 +++++++++++ .../Utils/Extensions/Tag+Constants.swift | 2 +- .../Tests/Utils/Pages/StubPage.swift | 23 ++- 8 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 7875f6b..7ab842e 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -4,8 +4,8 @@ import Foundation /// A page of a website: an HTML document with the shared scaffolding assembled around the page's content. /// /// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the -/// rest of the document around them: the viewport declaration and stylesheet links followed by the metadata in the head, and the content followed by -/// the script tags in the body. +/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, and the metadata followed by the +/// stylesheet links in the head, and the content followed by the script tags in the body. public protocol Page: HTMLDocument, Sendable { // MARK: Associated types @@ -21,6 +21,9 @@ public protocol Page: HTMLDocument, Sendable { /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. var assetVersion: String? { get } + /// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag. + var canonicalURL: String? { get } + /// The page's markup, rendered before the ``scripts``. @HTMLBuilder var content: Content { get } @@ -28,30 +31,41 @@ public protocol Page: HTMLDocument, Sendable { /// The locale the page content is localized to. var locale: Locale { get } - /// The markup placed in the document head after the ``stylesheets``: icon and manifest - /// links, extra meta tags, and the like. + /// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like. @HTMLBuilder var metadata: Metadata { get } /// The scripts loaded at the end of the document body, in order. var scripts: [any Asset] { get } + /// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default) + /// to omit them. + var socialCard: SocialCard? { get } + /// The stylesheets linked in the document head, in order. var stylesheets: [any Asset] { get } + /// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag. + var summary: String? { get } + } // MARK: - Implementations public extension Page { - + // MARK: Computed - + + /// The canonical URL is omitted unless the page provides one. + var canonicalURL: String? { + nil + } + /// The page ``content`` followed by its ``scripts``. @HTMLBuilder var body: some HTML { content - + for file in scripts { script(.src(file.urlPath( for: .js, @@ -59,8 +73,9 @@ public extension Page { ))) {} } } - - /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. + + /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags (when provided), and the ``metadata`` + /// followed by the ``stylesheets`` links, placed in the document head. /// /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, /// and HTML5 allows only one. @@ -70,9 +85,35 @@ public extension Page { .name(.viewport), .content("width=device-width, initial-scale=1") ) + + if let summary { + meta( + .name(.description), + .content(summary) + ) + } + + if let canonicalURL { + link( + .rel("canonical"), + .href(canonicalURL) + ) + } + + if let socialCard { + for tag in socialCard.tags { + meta( + .custom( + name: tag.attribute.rawValue, + value: tag.name.rawValue + ), + .content(tag.content) + ) + } + } metadata - + for file in stylesheets { link( .rel(.stylesheet), @@ -83,5 +124,15 @@ public extension Page { ) } } + + /// The social card is omitted unless the page provides one. + var socialCard: SocialCard? { + nil + } + /// The summary is omitted unless the page provides one. + var summary: String? { + nil + } + } diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift new file mode 100644 index 0000000..101ef61 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -0,0 +1,138 @@ +/// The content of a page's link-preview card, rendered as Open Graph and Twitter meta tags in the document head. +/// +/// The card carries the facts a link scraper reads — the page's title, summary, URL, and share image — and derives the meta ``tags`` expressing +/// them. Scrapers require absolute URLs, so the card takes ``url`` and ``Image/url`` fully formed; composing them from an origin and a versioned +/// asset path stays with the page providing the card. +public struct SocialCard: Sendable { + + // MARK: Properties + + /// The card's share image, or `nil` to omit its tags. + public let image: Image? + + /// The locale of the card's text, or `nil` to omit its tag. + /// + /// Open Graph specifies the `language_TERRITORY` form (e.g. `en_US`); scrapers also accept a bare language code (e.g. `en`). + public let locale: String? + + /// The name of the site the card belongs to, or `nil` to omit its tag. + public let siteName: String? + + /// The layout a Twitter card scraper gives the card. + public let style: Style + + /// The card's summary, or `nil` to omit its tag. + public let summary: String? + + /// The card's title. + public let title: String + + /// The Open Graph type of the object the card describes. + public let type: String + + /// The absolute URL the card's page is served at, or `nil` to omit its tag. + public let url: String? + + // MARK: Initializers + + /// Creates a link-preview card. + /// - Parameters: + /// - title: the card's title. + /// - summary: the card's summary, or `nil` (the default) to omit its tag. + /// - url: the absolute URL the card's page is served at, or `nil` (the default) to omit its tag. + /// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag. + /// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default) + /// to omit its tag. + /// - image: the card's share image, or `nil` (the default) to omit its tags. + /// - type: the Open Graph type of the object the card describes. Defaults to `website`. + /// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``. + public init( + title: String, + summary: String? = nil, + url: String? = nil, + siteName: String? = nil, + locale: String? = nil, + image: Image? = nil, + type: String = "website", + style: Style = .summaryLargeImage + ) { + self.image = image + self.locale = locale + self.siteName = siteName + self.style = style + self.summary = summary + self.title = title + self.type = type + self.url = url + } + + // MARK: Computed + + /// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and + /// the Twitter card style last. A tag whose fact the card does not carry is left out. + public var tags: [SocialCardTag] { + let tags: [SocialCardTag?] = [ + SocialCardTag( + attribute: .property, + content: type, + name: .type + ), + siteName.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .siteName + ) + }, + SocialCardTag( + attribute: .property, + content: title, + name: .title + ), + summary.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .description + ) + }, + url.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .url + ) + }, + locale.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .locale + ) + }, + ] + (image?.tags ?? []) + [ + SocialCardTag( + attribute: .name, + content: style.rawValue, + name: .twitter + ), + ] + + return tags.compactMap { $0 } + } + +} + +// MARK: - Enumerations + +public extension SocialCard { + + /// The layout a Twitter card scraper gives a ``SocialCard``. + enum Style: String, Sendable { + /// A compact card with a small thumbnail. + case summary + /// A card with a large image above the text. + case summaryLargeImage = "summary_large_image" + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift new file mode 100644 index 0000000..d00398f --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift @@ -0,0 +1,72 @@ +extension SocialCard { + /// The share image of a ``SocialCard``. + public struct Image: Sendable { + + // MARK: Properties + + /// The image's text for assistive technologies, or `nil` to omit it. + public let alt: String? + + /// The image's height in pixels, letting scrapers lay the card out before fetching the image. + public let height: Int + + /// The absolute URL the image is served at. + public let url: String + + /// The image's width in pixels, letting scrapers lay the card out before fetching the image. + public let width: Int + + // MARK: Initializers + + /// Creates a share image. + /// - Parameters: + /// - url: the absolute URL the image is served at. + /// - width: the image's width in pixels. + /// - height: the image's height in pixels. + /// - alt: the image's text for assistive technologies, or `nil` (the default) to omit it. + public init( + url: String, + width: Int, + height: Int, + alt: String? = nil + ) { + self.alt = alt + self.height = height + self.url = url + self.width = width + } + + // MARK: Computed + + /// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one. + public var tags: [SocialCardTag] { + let tags: [SocialCardTag?] = [ + SocialCardTag( + attribute: .property, + content: url, + name: .image + ), + SocialCardTag( + attribute: .property, + content: String(width), + name: .imageWidth + ), + SocialCardTag( + attribute: .property, + content: String(height), + name: .imageHeight + ), + alt.map { + SocialCardTag( + attribute: .property, + content: $0, + name: .imageAlt + ) + }, + ] + + return tags.compactMap { $0 } + } + + } +} diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift new file mode 100644 index 0000000..414782b --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift @@ -0,0 +1,55 @@ +/// A head meta tag of a ``SocialCard``: which attribute keys it, and its name and content. +public struct SocialCardTag: Equatable, Sendable { + + // MARK: Properties + + /// The attribute the tag is keyed by. + public let attribute: Attribute + + /// The tag's value. + public let content: String + + /// The tag's name. + public let name: Name + +} + +// MARK: - Enumerations + +extension SocialCardTag { + + /// The meta attribute a ``SocialCardTag`` is keyed by, named by its raw value. + public enum Attribute: String, Sendable { + /// The `name` attribute, keying the Twitter tags. + case name + /// The `property` attribute, keying the Open Graph tags. + case property + } + + /// The name of a ``SocialCardTag``, carried in its raw value. + public enum Name: String, Sendable { + /// The `og:description` tag, carrying the card's summary. + case description = "og:description" + /// The `og:image` tag, carrying the share image's absolute URL. + case image = "og:image" + /// The `og:image:alt` tag, carrying the share image's text for assistive technologies. + case imageAlt = "og:image:alt" + /// The `og:image:width` tag, carrying the share image's width in pixels. + case imageWidth = "og:image:width" + /// The `og:image:height` tag, carrying the share image's height in pixels. + case imageHeight = "og:image:height" + /// The `og:locale` tag, carrying the locale of the card's text. + case locale = "og:locale" + /// The `og:site_name` tag, carrying the name of the site the card belongs to. + case siteName = "og:site_name" + /// The `og:title` tag, carrying the card's title. + case title = "og:title" + /// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card. + case twitter = "twitter:card" + /// The `og:type` tag, carrying the Open Graph type of the object the card describes. + case type = "og:type" + /// The `og:url` tag, carrying the absolute URL the card's page is served at. + case url = "og:url" + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index 485d65e..b1a10af 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -47,6 +47,56 @@ struct PageTests { #expect(content.lowerBound < script.lowerBound) } + @Test + func `omits the summary and canonical tags by default`() { + let html = StubPage().render() + + #expect(!html.contains(#"name="description""#)) + #expect(!html.contains(#"rel="canonical""#)) + } + + @Test + func `renders the summary and canonical tags when provided`() { + let html = StubPage( + canonicalURL: "https://stub.example/", + summary: "A stub page." + ).render() + + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + } + + @Test + func `omits the social card tags by default`() { + let html = StubPage().render() + + #expect(!html.contains(#"property="og:"#)) + #expect(!html.contains(#"name="twitter:card""#)) + } + + @Test + func `renders the social card tags when provided`() { + let html = StubPage(socialCard: .init( + title: "Stub Page", + summary: "A stub page.", + url: "https://stub.example/", + image: .init( + url: "https://stub.example/img/card.png", + width: 2400, + height: 1260 + ) + )).render() + + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + } + @Test func `appends the version token to the asset URLs`() { let html = StubPage(assetVersion: "0123456789abcdef").render() diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift new file mode 100644 index 0000000..1282bb9 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -0,0 +1,84 @@ +import Testing + +@testable import Infrastructure + +@Suite( + "SocialCard type", + .tags(.type) +) +struct SocialCardTests { + + // MARK: Functional tests + + @Test + func `derives the full tag list from a complete card`() { + let card = SocialCard( + title: "A Title", + summary: "A summary.", + url: "https://site.example/", + siteName: "A Site", + locale: "en", + image: .init( + url: "https://site.example/img/card.png", + width: 2400, + height: 1260, + alt: "An image." + ) + ) + + #expect(card.tags == [ + .init(attribute: .property, content: "website", name: .type), + .init(attribute: .property, content: "A Site", name: .siteName), + .init(attribute: .property, content: "A Title", name: .title), + .init(attribute: .property, content: "A summary.", name: .description), + .init(attribute: .property, content: "https://site.example/", name: .url), + .init(attribute: .property, content: "en", name: .locale), + .init(attribute: .property, content: "https://site.example/img/card.png", name: .image), + .init(attribute: .property, content: "2400", name: .imageWidth), + .init(attribute: .property, content: "1260", name: .imageHeight), + .init(attribute: .property, content: "An image.", name: .imageAlt), + .init(attribute: .name, content: "summary_large_image", name: .twitter), + ]) + } + + @Test + func `omits the tags of the facts a minimal card does not carry`() { + let card = SocialCard(title: "A Title") + + #expect(card.tags == [ + .init(attribute: .property, content: "website", name: .type), + .init(attribute: .property, content: "A Title", name: .title), + .init(attribute: .name, content: "summary_large_image", name: .twitter), + ]) + } + + @Test + func `omits the image alt tag when the image carries none`() { + let card = SocialCard( + title: "A Title", + image: .init( + url: "https://site.example/img/card.png", + width: 2400, + height: 1260 + ) + ) + + let names = card.tags.map(\.name) + + #expect(names.contains(.image)) + #expect(!names.contains(.imageAlt)) + } + + @Test + func `carries the type and style it is given`() { + let card = SocialCard( + title: "A Title", + type: "article", + style: .summary + ) + + #expect(card.tags.contains(.init(attribute: .property, content: "article", name: .type))) + #expect(card.tags.contains(.init(attribute: .name, content: "summary", name: .twitter))) + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift index 9fec14b..1117cd8 100644 --- a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -9,6 +9,6 @@ extension Tag { @Tag static var middleware: Tag /// Tests exercising a protocol scaffolding of the Infrastructure package. @Tag static var `protocol`: Tag - /// Tests exercising an internal type of the Infrastructure package. + /// Tests exercising a type of the Infrastructure package. @Tag static var type: Tag } diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift index eae187d..68c8ac0 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -10,9 +10,18 @@ struct StubPage: Page { /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. let assetVersion: String? + /// The canonical URL rendered in the document head, or `nil` to omit it. + let canonicalURL: String? + /// The locale the page content is localized to. let locale: Locale + /// The card rendered as link-preview tags in the document head, or `nil` to omit them. + let socialCard: SocialCard? + + /// The summary rendered in the document head, or `nil` to omit it. + let summary: String? + // MARK: Initializers /// Creates a stub page. @@ -20,12 +29,24 @@ struct StubPage: Page { /// - locale: the locale the page content is localized to. Defaults to `en`. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the /// default) to leave them unversioned. + /// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default) + /// to omit it. + /// - socialCard: the card rendered as link-preview tags in the document head, or `nil` + /// (the default) to omit them. + /// - summary: the summary rendered in the document head, or `nil` (the default) + /// to omit it. init( locale: Locale = .init(identifier: "en"), - assetVersion: String? = nil + assetVersion: String? = nil, + canonicalURL: String? = nil, + socialCard: SocialCard? = nil, + summary: String? = nil ) { self.assetVersion = assetVersion + self.canonicalURL = canonicalURL self.locale = locale + self.socialCard = socialCard + self.summary = summary } // MARK: Computed -- 2.54.0 From 5f4316b85b59a88de151d2ed1860b3a4c0f7b335 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:15:27 +0000 Subject: [PATCH 031/117] Added the Utility package (#29) This PR contains the work done to create the new **Utility** package within the project, and also included in it the `NormalizeEmail` method, as it's not something that belongs to the **Infrastructure** package. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/29 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 8 ++- Packages/Utility/Package.swift | 31 ++++++++++ Packages/Utility/README.md | 31 ++++++++++ .../Public/Methods/NormalizeEmail.swift | 49 ++++++++++++++++ .../Public/Methods/NormalizeEmailTests.swift | 57 +++++++++++++++++++ .../Utils/Extensions/Tag+Constants.swift | 6 ++ Services/Website/Package.swift | 10 +++- Services/Website/Tests/Website.xctestplan | 7 +++ 8 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 Packages/Utility/Package.swift create mode 100644 Packages/Utility/README.md create mode 100644 Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift create mode 100644 Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift create mode 100644 Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index cade1d7..c7fdf23 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -8,13 +8,14 @@ The package provides, grouped by role: | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | | Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | +| Link previews | `SocialCard`, its `Image`, and the `SocialCardTag` meta tags it derives | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules The package holds only what every service can reuse; anything a service owns is injected, never referenced: -- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. +- **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 (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. @@ -29,7 +30,8 @@ Sources/ │ ├── Methods/ FingerprintAssets │ ├── Middlewares/ the five HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController -│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ └── Types/ SocialCard, with its image and tag types in SocialCard/ └── Internal/ └── Types/ implementation details (FNV1aHash) Tests/ @@ -43,4 +45,4 @@ Every suite carries a tag naming the kind of API it exercises — `.asset`, `.ex ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). -- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- macOS 15, matching the sibling `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms). diff --git a/Packages/Utility/Package.swift b/Packages/Utility/Package.swift new file mode 100644 index 0000000..574189c --- /dev/null +++ b/Packages/Utility/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Utility", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Utility", + targets: [ + "Utility" + ] + ) + ], + targets: [ + .target( + name: "Utility", + path: "Sources", + ), + .testTarget( + name: "UtilityTests", + dependencies: [ + .byName(name: "Utility") + ], + path: "Tests" + ), + ] +) diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md new file mode 100644 index 0000000..168ee32 --- /dev/null +++ b/Packages/Utility/README.md @@ -0,0 +1,31 @@ +# 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. + +## Overview +The package provides, grouped by role: +| Role | Types | +| --- | --- | +| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form | + +## Design rules +- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird). +- **Method structs.** Helpers hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +## Layout +Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +└── Public/ + └── Methods/ NormalizeEmail +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +└── Utils/ the suite Tag constants +``` + +## Testing +Every suite carries a tag naming the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Infrastructure`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- No package dependencies — Foundation only. diff --git a/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift new file mode 100644 index 0000000..6429a6c --- /dev/null +++ b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A validator reducing a submitted email address to its canonical form. +/// +/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can +/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes +/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address. +public struct NormalizeEmail: Sendable { + + // MARK: Initializers + + /// Creates an email normalization method. + public init() {} + + // MARK: Functions + + /// Validates and normalizes a submitted email address. + /// - Parameter email: the submitted email address, if any. + /// - Returns: the normalized address, or `nil` when the submission is missing or invalid. + public func callAsFunction( + _ email: String? + ) -> String? { + guard + let email = email? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + email.utf8.count <= Constant.Length.maxEmail, + !email.unicodeScalars.contains(where: { + $0.properties.generalCategory == .control + }), + email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil + else { + return nil + } + + return email + } + +} + +// MARK: - Constants + +private enum Constant { + /// A namespace for the length constants. + enum Length { + /// The longest an email address can be, in bytes, per RFC 5321's path limit. + static let maxEmail = 254 + } +} diff --git a/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift new file mode 100644 index 0000000..a26372e --- /dev/null +++ b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import Utility + +@Suite( + "NormalizeEmail method", + .tags(.method) +) +struct NormalizeEmailTests { + + // MARK: Properties + + private let normalize = NormalizeEmail() + + // MARK: Functional tests + + @Test(arguments: [ + ("fan@loudmail.nl", "fan@loudmail.nl"), + ("Fan@LoudMail.NL", "fan@loudmail.nl"), + (" fan@loudmail.nl\n", "fan@loudmail.nl"), + ("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"), + // Exactly 254 bytes: the longest address the validator accepts. + (String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"), + ]) + func `normalizes a valid address`( + submitted: String, + expected: String + ) { + #expect(normalize(submitted) == expected) + } + + @Test(arguments: [ + nil, + "", + " ", + "not-an-email", + "missing@dot", + "@loudmail.nl", + "fan@", + "fan@.nl", + "spaced out@loudmail.nl", + "fan@loud mail.nl", + // One byte over the 254-byte limit. + String(repeating: "a", count: 243) + "@loudmail.nl", + // Few enough characters, but multibyte ones put it over the byte limit. + String(repeating: "é", count: 130) + "@loudmail.nl", + // Control characters are not whitespace, so only the dedicated check catches them. + "fan\u{00}@loudmail.nl", + "fan@loudmail.nl\u{7F}", + ] as [String?]) + func `rejects a missing or invalid address`( + submitted: String? + ) { + #expect(normalize(submitted) == nil) + } + +} diff --git a/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..877f053 --- /dev/null +++ b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + /// Tests exercising a method of the Utility package. + @Tag static var method: Tag +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 71e1f67..f3539f9 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -20,6 +20,9 @@ let package = Package( ) ], dependencies: [ + .package( + path: "../../Packages/Infrastructure" + ), .package( path: "../../Packages/Localization" ), @@ -27,7 +30,7 @@ let package = Package( path: "../../Packages/Persistence" ), .package( - path: "../../Packages/Infrastructure" + path: "../../Packages/Utility" ), .package( url: "https://github.com/elementary-swift/elementary.git", @@ -79,9 +82,10 @@ let package = Package( .target( name: "WebsiteLibrary", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Localization"), .byName(name: "Persistence"), - .byName(name: "Infrastructure"), + .byName(name: "Utility"), .product( name: "Configuration", package: "swift-configuration" @@ -121,8 +125,8 @@ let package = Package( .testTarget( name: "WebsiteLibraryTests", dependencies: [ - .byName(name: "Persistence"), .byName(name: "Infrastructure"), + .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index b82c471..a16d3f0 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -52,6 +52,13 @@ "identifier" : "InfrastructureTests", "name" : "InfrastructureTests" } + }, + { + "target" : { + "containerPath" : "container:..\/..\/Packages\/Utility", + "identifier" : "UtilityTests", + "name" : "UtilityTests" + } } ], "version" : 1 -- 2.54.0 From 5c9fde9d41ffd4af36b2fe7a87afd91b75d82f31 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:25:36 +0000 Subject: [PATCH 032/117] Social Card tweaks in the Infrastructure package. (#30) This PR contains the work done to address certain tweaks in the newly-introduced _Social Card_ types in the **Infrastructure** package. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/30 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 2 +- .../Sources/Public/Types/SocialCard.swift | 54 +++------------ .../Types/SocialCard/SocialCardImage.swift | 30 ++------- .../Types/SocialCard/SocialCardTag.swift | 65 ++++++++++++++----- .../Cases/Public/Types/SocialCardTests.swift | 39 ++++++----- 5 files changed, 88 insertions(+), 102 deletions(-) diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index c7fdf23..5028528 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -8,7 +8,7 @@ The package provides, grouped by role: | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | | Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | -| Link previews | `SocialCard`, its `Image`, and the `SocialCardTag` meta tags it derives | +| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift index 101ef61..0fe4a74 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -70,52 +70,16 @@ public struct SocialCard: Sendable { /// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and /// the Twitter card style last. A tag whose fact the card does not carry is left out. - public var tags: [SocialCardTag] { - let tags: [SocialCardTag?] = [ - SocialCardTag( - attribute: .property, - content: type, - name: .type - ), - siteName.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .siteName - ) - }, - SocialCardTag( - attribute: .property, - content: title, - name: .title - ), - summary.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .description - ) - }, - url.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .url - ) - }, - locale.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .locale - ) - }, + public var tags: [Tag] { + let tags: [Tag?] = [ + Tag(type, name: .type), + siteName.map { Tag($0, name: .siteName) }, + Tag(title, name: .title), + summary.map { Tag($0, name: .description) }, + url.map { Tag($0, name: .url) }, + locale.map { Tag($0, name: .locale) }, ] + (image?.tags ?? []) + [ - SocialCardTag( - attribute: .name, - content: style.rawValue, - name: .twitter - ), + Tag(style.rawValue, name: .twitter), ] return tags.compactMap { $0 } diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift index d00398f..43f8575 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift @@ -39,30 +39,12 @@ extension SocialCard { // MARK: Computed /// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one. - public var tags: [SocialCardTag] { - let tags: [SocialCardTag?] = [ - SocialCardTag( - attribute: .property, - content: url, - name: .image - ), - SocialCardTag( - attribute: .property, - content: String(width), - name: .imageWidth - ), - SocialCardTag( - attribute: .property, - content: String(height), - name: .imageHeight - ), - alt.map { - SocialCardTag( - attribute: .property, - content: $0, - name: .imageAlt - ) - }, + public var tags: [Tag] { + let tags: [Tag?] = [ + Tag(url, name: .image), + Tag(String(width), name: .imageWidth), + Tag(String(height), name: .imageHeight), + alt.map { Tag($0, name: .imageAlt) }, ] return tags.compactMap { $0 } diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift index 414782b..ec446a5 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift @@ -1,24 +1,44 @@ -/// A head meta tag of a ``SocialCard``: which attribute keys it, and its name and content. -public struct SocialCardTag: Equatable, Sendable { +extension SocialCard { + /// A head meta tag of a ``SocialCard``: its name and content, keyed by the attribute its ``name`` dictates. + public struct Tag: Equatable, Sendable { - // MARK: Properties + // MARK: Properties - /// The attribute the tag is keyed by. - public let attribute: Attribute + /// The tag's value. + public let content: String - /// The tag's value. - public let content: String + /// The tag's name. + public let name: Name + + // MARK: Initializers - /// The tag's name. - public let name: Name + /// Creates a head meta tag. + /// - Parameters: + /// - content: the tag's value. + /// - name: the tag's name, dictating the attribute the tag is keyed by. + public init( + _ content: String, + name: Name + ) { + self.content = content + self.name = name + } + // MARK: Computed + + /// The attribute the tag is keyed by, dictated by its ``name``. + public var attribute: Attribute { + name.attribute + } + + } } // MARK: - Enumerations -extension SocialCardTag { - - /// The meta attribute a ``SocialCardTag`` is keyed by, named by its raw value. +extension SocialCard.Tag { + + /// The meta attribute a ``SocialCard/Tag`` is keyed by, named by its raw value. public enum Attribute: String, Sendable { /// The `name` attribute, keying the Twitter tags. case name @@ -26,7 +46,7 @@ extension SocialCardTag { case property } - /// The name of a ``SocialCardTag``, carried in its raw value. + /// The name of a ``SocialCard/Tag``, carried in its raw value. public enum Name: String, Sendable { /// The `og:description` tag, carrying the card's summary. case description = "og:description" @@ -34,10 +54,10 @@ extension SocialCardTag { case image = "og:image" /// The `og:image:alt` tag, carrying the share image's text for assistive technologies. case imageAlt = "og:image:alt" - /// The `og:image:width` tag, carrying the share image's width in pixels. - case imageWidth = "og:image:width" /// The `og:image:height` tag, carrying the share image's height in pixels. case imageHeight = "og:image:height" + /// The `og:image:width` tag, carrying the share image's width in pixels. + case imageWidth = "og:image:width" /// The `og:locale` tag, carrying the locale of the card's text. case locale = "og:locale" /// The `og:site_name` tag, carrying the name of the site the card belongs to. @@ -51,5 +71,18 @@ extension SocialCardTag { /// The `og:url` tag, carrying the absolute URL the card's page is served at. case url = "og:url" } - + +} + +// MARK: - Implementations + +public extension SocialCard.Tag.Name { + + // MARK: Computed + + /// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones. + var attribute: SocialCard.Tag.Attribute { + self == .twitter ? .name : .property + } + } diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift index 1282bb9..6849302 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -27,17 +27,17 @@ struct SocialCardTests { ) #expect(card.tags == [ - .init(attribute: .property, content: "website", name: .type), - .init(attribute: .property, content: "A Site", name: .siteName), - .init(attribute: .property, content: "A Title", name: .title), - .init(attribute: .property, content: "A summary.", name: .description), - .init(attribute: .property, content: "https://site.example/", name: .url), - .init(attribute: .property, content: "en", name: .locale), - .init(attribute: .property, content: "https://site.example/img/card.png", name: .image), - .init(attribute: .property, content: "2400", name: .imageWidth), - .init(attribute: .property, content: "1260", name: .imageHeight), - .init(attribute: .property, content: "An image.", name: .imageAlt), - .init(attribute: .name, content: "summary_large_image", name: .twitter), + .init("website", name: .type), + .init("A Site", name: .siteName), + .init("A Title", name: .title), + .init("A summary.", name: .description), + .init("https://site.example/", name: .url), + .init("en", name: .locale), + .init("https://site.example/img/card.png", name: .image), + .init("2400", name: .imageWidth), + .init("1260", name: .imageHeight), + .init("An image.", name: .imageAlt), + .init("summary_large_image", name: .twitter), ]) } @@ -46,9 +46,9 @@ struct SocialCardTests { let card = SocialCard(title: "A Title") #expect(card.tags == [ - .init(attribute: .property, content: "website", name: .type), - .init(attribute: .property, content: "A Title", name: .title), - .init(attribute: .name, content: "summary_large_image", name: .twitter), + .init("website", name: .type), + .init("A Title", name: .title), + .init("summary_large_image", name: .twitter), ]) } @@ -77,8 +77,15 @@ struct SocialCardTests { style: .summary ) - #expect(card.tags.contains(.init(attribute: .property, content: "article", name: .type))) - #expect(card.tags.contains(.init(attribute: .name, content: "summary", name: .twitter))) + #expect(card.tags.contains(.init("article", name: .type))) + #expect(card.tags.contains(.init("summary", name: .twitter))) + } + + @Test + func `keys a tag by the attribute its name dictates`() { + #expect(SocialCard.Tag.Name.twitter.attribute == .name) + #expect(SocialCard.Tag.Name.title.attribute == .property) + #expect(SocialCard.Tag("website", name: .type).attribute == .property) } } -- 2.54.0 From 1d1f0a9dc9ddffd9067cb984e5cd432de666cab0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 11:53:36 +0000 Subject: [PATCH 033/117] Utility package docker fix in the Website service target (#31) This PR contains the work done to included the Utility package into the Docker deployment process in the Website service target. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/31 Co-authored-by: Javier Cicchelli --- Services/Website/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 9693882..0714bda 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -48,6 +48,7 @@ WORKDIR /build COPY ./Packages/Localization/Package.swift ./Packages/Localization/ COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/ COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/ +COPY ./Packages/Utility/Package.swift ./Packages/Utility/ COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ RUN swift package --package-path ./Services/Website resolve @@ -56,6 +57,7 @@ RUN swift package --package-path ./Services/Website resolve COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources +COPY ./Packages/Utility/Sources ./Packages/Utility/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests -- 2.54.0 From b680ae0689b337929087c49afd4fc057795ba161 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 17:21:16 +0000 Subject: [PATCH 034/117] Structured Data support for the Page protocol in the Infrastructure package (#32) This PR contains the work done to introduce a `StructuredData` type that pages use to describe themselves to search engines as schema.org JSON-LD, and wires it into the Page protocol so the payload renders automatically in the document head. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/32 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 8 +- .../Extensions/String+Separators.swift | 5 + .../Sources/Public/Protocols/Page.swift | 28 ++- .../Sources/Public/Types/StructuredData.swift | 106 ++++++++++++ .../StructuredData/StructuredDataNode.swift | 92 ++++++++++ .../StructuredDataProperty.swift | 82 +++++++++ .../StructuredData/StructuredDataValue.swift | 67 ++++++++ .../Cases/Public/Protocols/PageTests.swift | 25 +++ .../Public/Types/StructuredDataTests.swift | 159 ++++++++++++++++++ .../Tests/Utils/Pages/StubPage.swift | 7 + 10 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 5028528..b724c15 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -9,14 +9,15 @@ The package provides, grouped by role: | Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | | Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives | +| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules The package holds only what every service can reuse; anything a service owns is injected, never referenced: -- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card. -- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. +- **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 (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them. +- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. ## Layout @@ -31,8 +32,9 @@ Sources/ │ ├── Middlewares/ the five HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController │ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse -│ └── Types/ SocialCard, with its image and tag types in SocialCard/ +│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/ └── Internal/ + ├── Extensions/ implementation details (the String separators) └── Types/ implementation details (FNV1aHash) Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout diff --git a/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift new file mode 100644 index 0000000..758fc4d --- /dev/null +++ b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift @@ -0,0 +1,5 @@ +extension String { + enum Separator { + static let comma = "," + } +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 7ab842e..30c25e1 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -4,8 +4,8 @@ import Foundation /// A page of a website: an HTML document with the shared scaffolding assembled around the page's content. /// /// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the -/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, and the metadata followed by the -/// stylesheet links in the head, and the content followed by the script tags in the body. +/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the +/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body. public protocol Page: HTMLDocument, Sendable { // MARK: Associated types @@ -42,6 +42,9 @@ public protocol Page: HTMLDocument, Sendable { /// to omit them. var socialCard: SocialCard? { get } + /// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it. + var structuredData: StructuredData? { get } + /// The stylesheets linked in the document head, in order. var stylesheets: [any Asset] { get } @@ -74,11 +77,14 @@ public extension Page { } } - /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags (when provided), and the ``metadata`` - /// followed by the ``stylesheets`` links, placed in the document head. + /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script + /// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head. /// /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, /// and HTML5 allows only one. + /// + /// The structured data is an inert data block — browsers never execute it, so a site's `Content-Security-Policy` does not apply to it — + /// that search engines read for the organization's name, logo, and profiles. @HTMLBuilder var head: some HTML { meta( @@ -112,6 +118,15 @@ public extension Page { } } + if let structuredData { + script(.custom( + name: "type", + value: "application/ld+json" + )) { + HTMLRaw(structuredData.payload) + } + } + metadata for file in stylesheets { @@ -130,6 +145,11 @@ public extension Page { nil } + /// The structured data is omitted unless the page provides one. + var structuredData: StructuredData? { + nil + } + /// The summary is omitted unless the page provides one. var summary: String? { nil diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift new file mode 100644 index 0000000..5e5addb --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift @@ -0,0 +1,106 @@ +/// The structured data of a page, rendered as a JSON-LD script in the document head. +/// +/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render +/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own +/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares. +/// +/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the +/// page providing the data. +public struct StructuredData: Equatable, Sendable { + + // MARK: Properties + + /// The schema.org nodes of the data's graph, in the order they render. + public let nodes: [Node] + + // MARK: Initializers + + /// Creates structured data from the nodes of its graph. + /// - Parameter nodes: the schema.org nodes of the data's graph, in the order they render. + public init( + nodes: [Node] + ) { + self.nodes = nodes + } + + // MARK: Computed + + /// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`. + /// + /// The values are rendered as JSON string literals with `<` escaped as well, so a value can never close the `script` tag embedding + /// the payload. + public var payload: String { + #"{"@context":"https://schema.org","@graph":[\#(fragments)]}"# + } + +} + +// MARK: - Initializers + +public extension StructuredData { + + /// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node + /// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived + /// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions. + /// A property whose fact the data does not carry is left out. + /// - Parameters: + /// - name: the name of the organization and the site. + /// - url: the absolute URL the site is served at. + /// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property. + /// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property. + init( + name: String, + url: String, + logo: String? = nil, + profiles: [String] = [] + ) { + let id = url + "#organization" + + var organization: [Property] = [ + .init(.name, value: .string(name)), + .init(.url, value: .string(url)), + ] + + if let logo { + organization.append(.init(.logo, value:.string(logo))) + } + + if !profiles.isEmpty { + organization.append(.init( + .sameAs, + value: .array(profiles.map(Value.string)) + )) + } + + self.init(nodes: [ + .init( + type: .organization, + id: id, + properties: organization + ), + .init( + type: .website, + properties: [ + .init(.name, value: .string(name)), + .init(.url, value: .string(url)), + .init(.publisher, value: .reference(id)), + ] + ), + ]) + } + +} + +// MARK: - Helpers + +private extension StructuredData { + + // MARK: Computed + + var fragments: String { + nodes + .map(\.fragment) + .joined(separator: .Separator.comma) + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift new file mode 100644 index 0000000..6ba19af --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift @@ -0,0 +1,92 @@ +extension StructuredData { + /// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties. + public struct Node: Equatable, Sendable { + + // MARK: Properties + + /// The node's identifier, rendered as its `@id` property, or `nil` to omit it. + /// + /// Another node references this node through ``Value/reference(_:)`` with the same identifier. + public let id: String? + + /// The node's properties, in the order they render after the type and identifier. + public let properties: [Property] + + /// The node's schema.org type (e.g. ``Kind/organization``), rendered as its `@type` property. + public let type: Kind + + // MARK: Initializers + + /// Creates a node. + /// - Parameters: + /// - type: the node's schema.org type (e.g. ``Kind/organization``). + /// - id: the node's identifier, or `nil` (the default) to omit it. + /// - properties: the node's properties, in the order they render. + public init( + type: Kind, + id: String? = nil, + properties: [Property] + ) { + self.id = id + self.properties = properties + self.type = type + } + + // MARK: Computed + + /// The node's minified JSON object: the `@type`, the `@id` (when the node carries one), and the ``properties`` in order. + var fragment: String { + var members = [#""@type":\#(Value.literal(type.rawValue))"#] + + if let id { + members.append(#""@id":\#(Value.literal(id))"#) + } + + members += properties.map(\.fragment) + + return "{\(members.joined(separator: .Separator.comma))}" + } + + } +} + +// MARK: - Structures + +extension StructuredData.Node { + /// The schema.org type of a ``StructuredData/Node``. + /// + /// Schema.org's vocabulary is open, so the kind is a typed string rather than a closed enumeration: the kinds every service shares + /// come as constants, a service declares the kinds its own node shapes need in an extension, and a one-off kind can be spelled as a + /// string literal. + public struct Kind: Equatable, ExpressibleByStringLiteral, Sendable { + + // MARK: Properties + + /// The type as it renders in the payload. + public let rawValue: String + + // MARK: Initializers + + /// Creates a kind. + /// - Parameter rawValue: the type as it renders in the payload. + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// Creates a kind from a string literal. + /// - Parameter value: the type as it renders in the payload. + public init(stringLiteral value: String) { + self.init(value) + } + + } +} + +// MARK: - Constants + +public extension StructuredData.Node.Kind { + /// An organization, e.g. the one publishing a website. + static let organization: Self = "Organization" + /// A website. + static let website: Self = "WebSite" +} diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift new file mode 100644 index 0000000..fb52cb0 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift @@ -0,0 +1,82 @@ +extension StructuredData { + /// A named property of a ``Node``, in the position it renders. + public struct Property: Equatable, Sendable { + + // MARK: Properties + + /// The property's schema.org name (e.g. `sameAs`). + public let name: Name + + /// The property's value. + public let value: Value + + // MARK: Initializers + + /// Creates a property. + /// - Parameters: + /// - name: the property's schema.org name (e.g. `sameAs`). + /// - value: the property's value. + public init( + _ name: Name, + value: Value + ) { + self.name = name + self.value = value + } + + // MARK: Computed + + /// The property's minified JSON member: its name and its rendered value. + var fragment: String { + #"\#(Value.literal(name.rawValue)):\#(value.fragment)"# + } + + } +} + +// MARK: - Structures + +extension StructuredData.Property { + /// The schema.org name of a ``StructuredData/Property``. + /// + /// Schema.org's vocabulary is open, so the name is a typed string rather than a closed enumeration: the names every service shares + /// come as constants, a service declares the names its own node shapes need in an extension, and a one-off name can be spelled as a + /// string literal. + public struct Name: Equatable, ExpressibleByStringLiteral, Sendable { + + // MARK: Properties + + /// The name as it renders in the payload. + public let rawValue: String + + // MARK: Initializers + + /// Creates a name. + /// - Parameter rawValue: the name as it renders in the payload. + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// Creates a name from a string literal. + /// - Parameter value: the name as it renders in the payload. + public init(stringLiteral value: String) { + self.init(value) + } + + } +} + +// MARK: - Constants + +public extension StructuredData.Property.Name { + /// The absolute URL of an organization's logo. + static let logo: Self = "logo" + /// The name of the thing a node describes. + static let name: Self = "name" + /// The organization publishing a website. + static let publisher: Self = "publisher" + /// The absolute URLs of the profiles that also identify the thing a node describes. + static let sameAs: Self = "sameAs" + /// The absolute URL of the thing a node describes. + static let url: Self = "url" +} diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift new file mode 100644 index 0000000..4ee5324 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift @@ -0,0 +1,67 @@ +extension StructuredData { + /// A value of a ``Property``: a string, a list, a nested node, or a reference to another node. + /// + /// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script` + /// tag embedding the payload it renders into. + public indirect enum Value: Equatable, Sendable { + /// A list of values. + case array([Value]) + /// A nested node, e.g. the place a schema.org event is located at. + case node(Node) + /// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object. + case reference(String) + /// A string value. + case string(String) + } +} + +// MARK: - Extensions + +extension StructuredData.Value { + + // MARK: Computed + + /// The value's minified JSON fragment. + var fragment: String { + switch self { + case .array(let values): + "[\(values.map(\.fragment).joined(separator: .Separator.comma))]" + case .node(let node): + node.fragment + case .reference(let id): + #"{"@id":\#(Self.literal(id))}"# + case .string(let string): + Self.literal(string) + } + } + + // MARK: Methods + + /// Renders a string as a JSON string literal, escaping `<` as well since the payload is embedded in a `script` tag the string + /// could otherwise close. + /// - Parameter value: the string to render. + /// - Returns: the quoted and escaped literal. + static func literal(_ value: String) -> String { + var literal = "\"" + + for scalar in value.unicodeScalars { + switch scalar { + case "\"": + literal += #"\""# + case "\\": + literal += #"\\"# + case "<": + literal += #"\u003c"# + case let scalar where scalar.value < 0x20: + let hex = String(scalar.value, radix: 16) + + literal += #"\u"# + String(repeating: "0", count: 4 - hex.count) + hex + default: + literal.unicodeScalars.append(scalar) + } + } + + return literal + "\"" + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index b1a10af..e0fc405 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -97,6 +97,31 @@ struct PageTests { #expect(html.contains(#""#)) } + @Test + func `omits the structured data script by default`() { + let html = StubPage().render() + + #expect(!html.contains("application/ld+json")) + } + + @Test + func `renders the structured data script when provided`() { + let html = StubPage(structuredData: .init( + name: "Stub Site", + url: "https://stub.example/", + logo: "https://stub.example/logo.png", + profiles: ["https://social.example/stub"] + )).render() + + #expect(html.contains( + #""# + )) + } + @Test func `appends the version token to the asset URLs`() { let html = StubPage(assetVersion: "0123456789abcdef").render() diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift new file mode 100644 index 0000000..f0bfcb3 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift @@ -0,0 +1,159 @@ +import Foundation +import Testing + +@testable import Infrastructure + +@Suite( + "StructuredData type", + .tags(.type) +) +struct StructuredDataTests { + + // MARK: Functional tests + + @Test + func `derives the full payload from complete data`() { + let data = StructuredData( + name: "A Site", + url: "https://site.example/", + logo: "https://site.example/logo.png", + profiles: [ + "https://social.example/a-site", + "https://videos.example/a-site", + ] + ) + + #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# + + #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# + + #""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# + + #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"# + ) + } + + @Test + func `omits the properties of the facts minimal data does not carry`() { + let data = StructuredData( + name: "A Site", + url: "https://site.example/" + ) + + #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# + + #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# + + #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"# + ) + } + + @Test + func `renders a composed node graph`() { + let data = StructuredData(nodes: [ + .init( + type: "MusicEvent", + properties: [ + .init(.name, value: .string("A Gig")), + .init("location", value: .node(.init( + type: "Place", + properties: [ + .init(.name, value: .string("A Venue")), + ] + ))), + .init("organizer", value: .reference("https://site.example/#organization")), + ] + ), + ]) + + #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# + + #"{"@type":"MusicEvent","name":"A Gig","location":{"@type":"Place","name":"A Venue"},"# + + #""organizer":{"@id":"https://site.example/#organization"}}]}"# + ) + } + + @Test + func `escapes the values it embeds in the payload`() { + let data = StructuredData( + name: #"A "Quoted" \ Site"#, + url: "https://site.example/" + ) + + #expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#)) + // The `<` is escaped so a value can never close the script tag embedding the payload. + #expect(!data.payload.contains("")) + #expect(data.payload.contains("\\" + "u003c/script>")) + } + + @Test + func `renders a node fragment with its identifier`() { + let node = StructuredData.Node( + type: "Organization", + id: "https://site.example/#organization", + properties: [ + .init(.name, value: .string("A Site")), + ] + ) + + #expect(node.fragment == #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site"}"#) + } + + @Test + func `renders a node fragment without an identifier or properties`() { + let node = StructuredData.Node( + type: "Organization", + properties: [] + ) + + #expect(node.fragment == #"{"@type":"Organization"}"#) + } + + @Test + func `renders the common names and kinds by their schema.org spelling`() { + #expect(StructuredData.Property.Name.logo.rawValue == "logo") + #expect(StructuredData.Property.Name.name.rawValue == "name") + #expect(StructuredData.Property.Name.publisher.rawValue == "publisher") + #expect(StructuredData.Property.Name.sameAs.rawValue == "sameAs") + #expect(StructuredData.Property.Name.url.rawValue == "url") + #expect(StructuredData.Node.Kind.organization.rawValue == "Organization") + #expect(StructuredData.Node.Kind.website.rawValue == "WebSite") + } + + @Test + func `renders the fragment of every value case`() { + #expect(StructuredData.Value.string("A Value").fragment == #""A Value""#) + #expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#) + #expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#) + #expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#) + } + + @Test + func `renders a string as a quoted literal`() { + #expect(StructuredData.Value.literal("A Value") == #""A Value""#) + #expect(StructuredData.Value.literal("") == "\"\"") + } + + @Test + func `pads the escape of a control character to four digits`() { + #expect(StructuredData.Value.literal("\u{0}") == "\"" + "\\" + "u0000" + "\"") + #expect(StructuredData.Value.literal("\u{1f}") == "\"" + "\\" + "u001f" + "\"") + #expect(StructuredData.Value.literal("\u{a}") == "\"" + "\\" + "u000a" + "\"") + // The first scalar past the control range passes through untouched. + #expect(StructuredData.Value.literal(" ") == #"" ""#) + } + + @Test + func `derives a payload that parses back to the facts it carries`() throws { + let name = "A \"Site\"\nwith \\ every " + let data = StructuredData( + name: name, + url: "https://site.example/", + logo: "https://site.example/logo.png", + profiles: ["https://social.example/a-site"] + ) + + let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8)) + let graph = try #require((object as? [String: Any])?["@graph"] as? [[String: Any]]) + + #expect(graph.count == 2) + #expect(graph[0]["name"] as? String == name) + #expect(graph[0]["sameAs"] as? [String] == ["https://social.example/a-site"]) + #expect(graph[1]["url"] as? String == "https://site.example/") + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift index 68c8ac0..ffc755e 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -19,6 +19,9 @@ struct StubPage: Page { /// The card rendered as link-preview tags in the document head, or `nil` to omit them. let socialCard: SocialCard? + /// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it. + let structuredData: StructuredData? + /// The summary rendered in the document head, or `nil` to omit it. let summary: String? @@ -33,6 +36,8 @@ struct StubPage: Page { /// to omit it. /// - socialCard: the card rendered as link-preview tags in the document head, or `nil` /// (the default) to omit them. + /// - structuredData: the structured data rendered as a JSON-LD script in the document + /// head, or `nil` (the default) to omit it. /// - summary: the summary rendered in the document head, or `nil` (the default) /// to omit it. init( @@ -40,12 +45,14 @@ struct StubPage: Page { assetVersion: String? = nil, canonicalURL: String? = nil, socialCard: SocialCard? = nil, + structuredData: StructuredData? = nil, summary: String? = nil ) { self.assetVersion = assetVersion self.canonicalURL = canonicalURL self.locale = locale self.socialCard = socialCard + self.structuredData = structuredData self.summary = summary } -- 2.54.0 From 2c571233bc22254ee0b364af9ca0c55446a5c090 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 23:06:22 +0200 Subject: [PATCH 035/117] Renamed the Error page in the Website library target as NotFound. --- .../Library/Catalogs/Localizable.xcstrings | 66 +++++++++---------- .../{ErrorPage.swift => NotFoundPage.swift} | 14 ++-- .../NotFoundMiddleware+Defaults.swift | 2 +- ...ageTests.swift => NotFoundPageTests.swift} | 6 +- 4 files changed, 44 insertions(+), 44 deletions(-) rename Services/Website/Sources/Library/Internal/Pages/{ErrorPage.swift => NotFoundPage.swift} (80%) rename Services/Website/Tests/Library/Cases/Internal/Pages/{ErrorPageTests.swift => NotFoundPageTests.swift} (89%) diff --git a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings index 9a6ccec..729eda6 100644 --- a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings +++ b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings @@ -1,39 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "error.heading" : { - "comment" : "The not-found page's main heading.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Page Not Found" - } - } - } - }, - "error.message" : { - "comment" : "The not-found page's body text.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sorry, but the page you were trying to view does not exist." - } - } - } - }, - "error.title" : { - "comment" : "The not-found page's document title.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Page Not Found" - } - } - } - }, "index.greeting" : { "comment" : "The landing page's greeting paragraph.", "localizations" : { @@ -55,6 +22,39 @@ } } } + }, + "notFound.heading" : { + "comment" : "The not-found page's main heading.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Not Found" + } + } + } + }, + "notFound.message" : { + "comment" : "The not-found page's body text.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorry, but the page you were trying to view does not exist." + } + } + } + }, + "notFound.title" : { + "comment" : "The not-found page's document title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Not Found" + } + } + } } }, "version" : "1.0" diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift similarity index 80% rename from Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift rename to Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift index 2b19f7b..f49730c 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift @@ -4,7 +4,7 @@ import Infrastructure import Localization /// The HTML page rendered for a not-found response, with its text localized to a given locale. -struct ErrorPage { +struct NotFoundPage { // MARK: Properties @@ -36,29 +36,29 @@ struct ErrorPage { // MARK: Page -extension ErrorPage: Page { +extension NotFoundPage: Page { // MARK: Properties var content: some HTML { h1 { - localize("error.heading", locale: locale) + localize("notFound.heading", locale: locale) } p { - localize("error.message", locale: locale) + localize("notFound.message", locale: locale) } } var scripts: [any Asset] { - [StaticFile.error, StaticFile.shared] + [StaticFile.notFound, StaticFile.shared] } var stylesheets: [any Asset] { - [StaticFile.shared, StaticFile.error] + [StaticFile.shared, StaticFile.notFound] } var title: String { - localize("error.title", locale: locale) + localize("notFound.title", locale: locale) } } diff --git a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift index 545dd1f..5c850f9 100644 --- a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift +++ b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift @@ -11,7 +11,7 @@ public extension NotFoundMiddleware { assetVersion: String? = nil ) { self.init(bundle: .module) { - ErrorPage( + NotFoundPage( locale: $0, assetVersion: assetVersion ) diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift similarity index 89% rename from Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift rename to Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift index 4a2771c..d29b2d0 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift @@ -5,16 +5,16 @@ import Testing @testable import WebsiteLibrary @Suite( - "ErrorPage page", + "NotFoundPage page", .tags(.page) ) -struct ErrorPageTests { +struct NotFoundPageTests { // MARK: Functional tests @Test func `renders its markup`() { - let html = ErrorPage( + let html = NotFoundPage( locale: .init(identifier: "en") ).render() -- 2.54.0 From a056f7f69ac9044dd5b66c54d16b6f2d55dd8f9f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 23:07:04 +0200 Subject: [PATCH 036/117] Renamed the "error" static assets at the Resources folder in the Website service target as "not-found". --- .../Resources/Static/css/{error.css => not-found.css} | 0 .../Resources/Static/js/{error.js => not-found.js} | 0 .../Library/Internal/Enumerations/StaticFile.swift | 10 +++++----- Services/Website/Tests/App/AppTests.swift | 2 +- .../Cases/Internal/Enumerations/StaticFileTests.swift | 4 ++-- .../Cases/Internal/Pages/NotFoundPageTests.swift | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) rename Services/Website/Resources/Static/css/{error.css => not-found.css} (100%) rename Services/Website/Resources/Static/js/{error.js => not-found.js} (100%) diff --git a/Services/Website/Resources/Static/css/error.css b/Services/Website/Resources/Static/css/not-found.css similarity index 100% rename from Services/Website/Resources/Static/css/error.css rename to Services/Website/Resources/Static/css/not-found.css diff --git a/Services/Website/Resources/Static/js/error.js b/Services/Website/Resources/Static/js/not-found.js similarity index 100% rename from Services/Website/Resources/Static/js/error.js rename to Services/Website/Resources/Static/js/not-found.js diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 3196f8f..945ca74 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -7,8 +7,6 @@ import Infrastructure enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon - /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. - case error /// The `favicon.ico` icon. case favicon /// The `icon.svg` icon. @@ -19,6 +17,8 @@ enum StaticFile: Asset, CaseIterable { case icon512 /// The `css/index.css` stylesheet and `js/index.js` script for the landing page. case index + /// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page. + case notFound /// The `robots.txt` crawler directives. case robots /// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages. @@ -41,8 +41,8 @@ extension StaticFile { case .appleTouchIcon, .icon192, .icon512: [.png] - case .error, - .index, + case .index, + .notFound, .shared: [.css, .js] case .favicon: [.ico] case .icon: [.svg] @@ -56,12 +56,12 @@ extension StaticFile { var fileName: String { switch self { case .appleTouchIcon: "apple-touch-icon" - case .error: "error" case .favicon: "favicon" case .icon: "icon" case .icon192: "icon-192" case .icon512: "icon-512" case .index: "index" + case .notFound: "not-found" case .robots: "robots" case .shared: "shared" case .site: "site" diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 70fce3c..846ccf2 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -268,7 +268,7 @@ struct AppTests { ) { response in let body = String(buffer: response.body) - #expect(body.contains("/css/error.css?v=")) + #expect(body.contains("/css/not-found.css?v=")) #expect(body.contains("/js/shared.js?v=")) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 0c141e5..b675544 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -54,12 +54,12 @@ private extension StaticFileTests { static let fileExtensions: [[AssetExtension]] = [ [.png], - [.css, .js], [.ico], [.svg], [.png], [.png], [.css, .js], + [.css, .js], [.txt], [.css, .js], [.webmanifest], @@ -67,12 +67,12 @@ private extension StaticFileTests { ] static let fileNames: [String] = [ "apple-touch-icon", - "error", "favicon", "icon", "icon-192", "icon-512", "index", + "not-found", "robots", "shared", "site", diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift index d29b2d0..21786cd 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/NotFoundPageTests.swift @@ -23,8 +23,8 @@ struct NotFoundPageTests { #expect(html.contains("Page Not Found")) #expect(html.contains("Sorry, but the page you were trying to view does not exist.")) #expect(html.contains("/css/shared.css")) - #expect(html.contains("/css/error.css")) - #expect(html.contains("/js/error.js")) + #expect(html.contains("/css/not-found.css")) + #expect(html.contains("/js/not-found.js")) #expect(html.contains("/js/shared.js")) } -- 2.54.0 From 64d2d5943af7ca6a603b7fd23af55c737a118147 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 2 Aug 2026 00:58:11 +0200 Subject: [PATCH 037/117] Updated the documentation of the README file in the Website service target. --- Services/Website/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Services/Website/README.md b/Services/Website/README.md index ec2eaaa..f2325ac 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -7,7 +7,7 @@ The service: - Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. - Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable). - Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)). -- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file. +- Returns a custom not-found (404) HTML page, localized like the landing 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. - Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key. @@ -22,12 +22,13 @@ Two SwiftPM targets: | Target | Kind | Path | Role | | --- | --- | --- | --- | | `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. | -| `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. | +| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages, the `StaticFile` asset catalog, the request context, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. | -The `Website` executable depends on three local packages: +The `Website` executable depends on four local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). - `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. +- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). @@ -38,7 +39,7 @@ LogRequestsMiddleware → VaryMiddleware (marks every response as varying on Accept-Encoding) → ResponseCompressionMiddleware (gzip/deflate above the size threshold) → LocalizationMiddleware (negotiates the request's language) - → NotFoundMiddleware (renders the localized 404 page on .notFound) + → NotFoundMiddleware (renders the localized not-found page on .notFound) → FileMiddleware (serves Resources/Static) RootController (GET / → landing page) HealthController (GET /health → liveness, GET /health/ready → readiness) @@ -184,7 +185,7 @@ 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 `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, and `LocalizationTests`. +Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database: ```sh -- 2.54.0 From a5ee02bb0b79a447370b2de840afb33acbddfa3f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 2 Aug 2026 02:41:46 +0200 Subject: [PATCH 038/117] Improved the PNG assets optimization on the Dockerfile in the Website service target to do it recursively. --- Services/Website/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index 0714bda..c1c6879 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -19,7 +19,7 @@ WORKDIR /static COPY ./Services/Website/Resources/Static . RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \ && esbuild --minify --allow-overwrite --outdir=js js/*.js \ - && oxipng --opt max --strip safe *.png \ + && oxipng --opt max --strip safe --recursive . \ && svgo --recursive --folder . # Export stage: `docker build --target assets-export --output ` writes the minified static files to for -- 2.54.0 From 422560ec1090e21cde0addbaaa58ae66a293c490 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 2 Aug 2026 02:57:43 +0200 Subject: [PATCH 039/117] Prompt the canonical site URL during bootstrap script to rewrite website crawlers. --- README.md | 8 +- Scripts/bootstrap | 45 ++++++++++++ Services/Website/README.md | 73 +++++++++++++++---- Services/Website/Resources/Static/robots.txt | 2 +- Services/Website/Resources/Static/sitemap.xml | 2 +- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 63eb852..1fa7fe0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ You'll be asked for: | --- | --- | --- | | **Site display name** (PascalCase) | `Loud` | Server name (`LoudWebsite`), `Loud.xcodeproj`, README title. | | **Project slug** (lowercase) | `loud-ams` | Container owner, database name/user, Compose project name. | +| **Canonical site URL** (scheme + host) | `https://loud.amsterdam` | The `Sitemap:` reference in `robots.txt` and the `` entry in `sitemap.xml`. | + +The canonical URL defaults to the reserved placeholder `https://site.example.com`; +leaving it there is allowed (the script warns), so you can bootstrap before the +domain is decided and set it later in the two crawler files. The script rewrites the placeholder occurrences in place, renames `Site.xcodeproj`, optionally starts a fresh git history, and then removes @@ -57,7 +62,8 @@ Bootstrap leaves reminders, but in short: 1. **Copy & localization** — `Services/Website/Sources/Library/Catalogs/Localizable.xcstrings` and the landing / 404 pages under `Services/Website/Sources/Library`. -2. **Static assets** — `Services/Website/Resources/Static` (CSS, JS, favicon, icons, manifest). +2. **Static assets** — `Services/Website/Resources/Static` (CSS, JS, favicon, icons). + The manifest's `name` / `short_name` are empty and are not rewritten by bootstrap. 3. **Secrets** — set a real `DATABASE_PASSWORD` in a git-ignored `Services/Website/.env` (the committed `.env.local` is an example only). 4. **Domain models** — the `Persistence` package ships a sample diff --git a/Scripts/bootstrap b/Scripts/bootstrap index 10aba68..8d44d58 100755 --- a/Scripts/bootstrap +++ b/Scripts/bootstrap @@ -24,6 +24,11 @@ fi XCODEPROJ="./Site.xcodeproj" +# The placeholder origin the crawler files ship with. It is an RFC 2606 reserved +# domain, so an un-bootstrapped copy can never point a crawler at a real site. +PLACEHOLDER_URL="https://site.example.com" +PLACEHOLDER_URL_PATTERN='https://site\.example\.com' + # --- Prompts ------------------------------------------------------------------ printf 'Site display name (PascalCase, e.g. Berlin) [Site]: ' @@ -35,9 +40,31 @@ printf 'Project slug (lowercase, used for owner/db/compose, e.g. loud-berlin) [% read -r SLUG SLUG="${SLUG:-$DEFAULT_SLUG}" +printf 'Canonical site URL (scheme + host, e.g. https://berlin.example.com) [%s]: ' "$PLACEHOLDER_URL" +read -r URL +URL="${URL:-$PLACEHOLDER_URL}" +URL="${URL%/}" # the rewrites append their own path, so drop a trailing slash + +# The URL is interpolated into a sed replacement (with `|` as the delimiter) and +# into the crawler files, so reject anything that is not a plain scheme + host. +case "$URL" in + http://* | https://*) ;; + *) + echo "error: the canonical site URL must start with http:// or https:// (got \"$URL\")." >&2 + exit 1 + ;; +esac +case "$URL" in + *[!-A-Za-z0-9:/._~]*) + echo "error: the canonical site URL contains unexpected characters (got \"$URL\")." >&2 + exit 1 + ;; +esac + echo echo " display name : $NAME (server name \"${NAME}Website\", ${NAME}.xcodeproj)" echo " slug : $SLUG (container owner, database name/user, compose project)" +echo " canonical URL: $URL (robots.txt sitemap reference, sitemap.xml entry)" echo printf 'Apply these values? [y/N]: ' read -r CONFIRM @@ -95,6 +122,14 @@ rewrite "$W/docker-compose.override.yml" \ rewrite "$W/Makefile" \ -e "s/),site)/),${SLUG})/g" +# Crawler files: the absolute origin they must carry. `|` is the sed delimiter, +# since both the placeholder and the replacement contain slashes. +rewrite "$W/Resources/Static/robots.txt" \ + -e "s|${PLACEHOLDER_URL_PATTERN}|${URL}|g" + +rewrite "$W/Resources/Static/sitemap.xml" \ + -e "s|${PLACEHOLDER_URL_PATTERN}|${URL}|g" + # Service README. rewrite "$W/README.md" \ -e "s/^# Site Website\$/# ${NAME} Website/" \ @@ -147,6 +182,7 @@ Next steps: - $W/Sources/Library/Catalogs/Localizable.xcstrings (copy) - $W/Sources/Library (landing / 404 pages) - $W/Resources/Static (css, js, favicon, icons) + - $W/Resources/Static/site.webmanifest (name / short_name) 2. Set a real database password in a git-ignored $W/.env (the committed .env.local defaults the password to the slug — do NOT ship that). 3. Point the git remote at your new repository: @@ -157,3 +193,12 @@ Next steps: The Persistence package still ships an ExampleRecord / ExampleRepository sample model — replace it with your real domain models when you add persistence. EOF + +if [ "$URL" = "$PLACEHOLDER_URL" ]; then + cat <` | The page's one-line description. | +| `canonicalURL` | `` | Absolute URL. | +| `socialCard` | Open Graph + Twitter `` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. | +| `structuredData` | `"#)) + } + + @Test + func `omits the analytics behavior flags that are disabled`() { + let html = StubPage(analytics: .init( + scriptURL: "https://analytics.example.com/script", + websiteID: "0000-website-id", + domains: "example.com", + excludeHash: true, + doNotTrack: false, + performance: false + )).render() + + #expect(html.contains(#""#)) + #expect(!html.contains("data-do-not-track")) + #expect(!html.contains("data-performance")) + } + @Test func `appends the version token to the asset URLs`() { let html = StubPage(assetVersion: "0123456789abcdef").render() diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift new file mode 100644 index 0000000..1ee9a42 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift @@ -0,0 +1,55 @@ +import Testing + +@testable import Infrastructure + +@Suite( + "Analytics type", + .tags(.type) +) +struct AnalyticsTests { + + // MARK: Functional tests + + @Test + func `lists the website id and domains with every behavior flag on by default`() { + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "example.com" + ) + + #expect(analytics.attributes.map(\.name) == [ + "data-website-id", + "data-domains", + "data-exclude-hash", + "data-do-not-track", + "data-performance", + ]) + #expect(analytics.attributes.map(\.value) == [ + "id-123", + "example.com", + "true", + "true", + "true", + ]) + } + + @Test + func `omits the disabled behavior flags`() { + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "example.com", + excludeHash: true, + doNotTrack: false, + performance: false + ) + + #expect(analytics.attributes.map(\.name) == [ + "data-website-id", + "data-domains", + "data-exclude-hash", + ]) + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift index ffc755e..d719f1d 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -7,6 +7,9 @@ struct StubPage: Page { // MARK: Properties + /// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it. + let analytics: Analytics? + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. let assetVersion: String? @@ -34,6 +37,8 @@ struct StubPage: Page { /// default) to leave them unversioned. /// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default) /// to omit it. + /// - analytics: the analytics tracker rendered as a deferred script in the document head, + /// or `nil` (the default) to omit it. /// - socialCard: the card rendered as link-preview tags in the document head, or `nil` /// (the default) to omit them. /// - structuredData: the structured data rendered as a JSON-LD script in the document @@ -44,10 +49,12 @@ struct StubPage: Page { locale: Locale = .init(identifier: "en"), assetVersion: String? = nil, canonicalURL: String? = nil, + analytics: Analytics? = nil, socialCard: SocialCard? = nil, structuredData: StructuredData? = nil, summary: String? = nil ) { + self.analytics = analytics self.assetVersion = assetVersion self.canonicalURL = canonicalURL self.locale = locale -- 2.54.0 From c603421c28858701c137ef1032e17c69c573d82e Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 4 Aug 2026 12:06:47 +0200 Subject: [PATCH 044/117] Implemented the scripts' deferral on the Page protocol in the Infrastructure package. --- .../Sources/Public/Protocols/Page.swift | 31 ++++++++++++------- .../Cases/Public/Protocols/PageTests.swift | 12 ++++--- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index e925ac3..5f71622 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -5,7 +5,7 @@ import Foundation /// /// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the /// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, the analytics -/// tracker script, and the metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body. +/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body. public protocol Page: HTMLDocument, Sendable { // MARK: Associated types @@ -27,7 +27,7 @@ public protocol Page: HTMLDocument, Sendable { /// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag. var canonicalURL: String? { get } - /// The page's markup, rendered before the ``scripts``. + /// The page's markup, rendered as the document body. @HTMLBuilder var content: Content { get } @@ -38,7 +38,10 @@ public protocol Page: HTMLDocument, Sendable { @HTMLBuilder var metadata: Metadata { get } - /// The scripts loaded at the end of the document body, in order. + /// The scripts loaded from the document head, in order. + /// + /// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the + /// document is fully parsed — the same semantics end-of-body tags would give, minus the late download start. var scripts: [any Asset] { get } /// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default) @@ -67,21 +70,15 @@ public extension Page { nil } - /// The page ``content`` followed by its ``scripts``. + /// The page ``content``; the ``scripts`` load deferred from the ``head``. @HTMLBuilder var body: some HTML { content - - for file in scripts { - script(.src(file.urlPath( - for: .js, - version: assetVersion - ))) {} - } } /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags, the ``structuredData`` script and the - /// ``analytics`` tracker script (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head. + /// ``analytics`` tracker script (when provided), and the ``metadata`` followed by the ``stylesheets`` links and the deferred + /// ``scripts`` tags, placed in the document head. /// /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, /// and HTML5 allows only one. @@ -155,6 +152,16 @@ public extension Page { )) ) } + + for file in scripts { + script( + .defer, + .src(file.urlPath( + for: .js, + version: assetVersion + )) + ) {} + } } /// The analytics tracker is omitted unless the page provides one. diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift index 2540ec2..38787fa 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -21,7 +21,7 @@ struct PageTests { #expect(html.contains(#"name="viewport""#)) #expect(html.contains(#""#)) #expect(html.contains(#""#)) - #expect(html.contains(#""#)) + #expect(html.contains(#""#)) #expect(html.contains("Stub content")) } @@ -38,13 +38,17 @@ struct PageTests { } @Test - func `renders the scripts after the content`() throws { + func `renders the scripts deferred in the head, after the stylesheets`() throws { let html = StubPage().render() - let content = try #require(html.range(of: "Stub content")) + let stylesheet = try #require(html.range(of: "/css/stub.css")) let script = try #require(html.range(of: "/js/stub.js")) + let content = try #require(html.range(of: "Stub content")) - #expect(content.lowerBound < script.lowerBound) + // Deferred head scripts start downloading during head parsing but still execute, in order, only after the + // document is parsed — the semantics end-of-body tags gave, minus the late download start. + #expect(stylesheet.lowerBound < script.lowerBound) + #expect(script.lowerBound < content.lowerBound) } @Test -- 2.54.0 From 16acdf0f3d992a18732842693547484d4106f2d5 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 4 Aug 2026 12:11:57 +0200 Subject: [PATCH 045/117] Implemented the analytics preconnect hint on the Page protocol in the Infrastructure package. --- .../Sources/Public/Protocols/Page.swift | 16 ++++++--- .../Sources/Public/Types/Analytics.swift | 20 +++++++++++ .../Cases/Public/Protocols/PageTests.swift | 9 ++++- .../Cases/Public/Types/AnalyticsTests.swift | 33 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index 5f71622..952ea4c 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -76,9 +76,9 @@ public extension Page { content } - /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags, the ``structuredData`` script and the - /// ``analytics`` tracker script (when provided), and the ``metadata`` followed by the ``stylesheets`` links and the deferred - /// ``scripts`` tags, placed in the document head. + /// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard`` + /// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the + /// ``stylesheets`` links and the deferred ``scripts`` tags, placed in the document head. /// /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup, /// and HTML5 allows only one. @@ -92,7 +92,15 @@ public extension Page { .name(.viewport), .content("width=device-width, initial-scale=1") ) - + + // Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag. + if let origin = analytics?.origin { + link( + .rel("preconnect"), + .href(origin) + ) + } + if let summary { meta( .name(.description), diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift index 4df4997..378df23 100644 --- a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -1,3 +1,5 @@ +import Foundation + /// The analytics tracker a page embeds: where the script loads from, which site it reports as, which domains it reports from, and how it behaves. /// /// A page carries it as an optional value so the ``Page`` scaffolding renders the tracker's deferred `"#)) + + // The preconnect hint warms the tracker origin's connection before the parser reaches the script tag. + let preconnect = try #require(html.range(of: #""#)) + let script = try #require(html.range(of: #""#)) + + let tracker = try #require(html.range(of: #"