diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift index 277db4a..2323799 100644 --- a/Packages/Localization/Package.swift +++ b/Packages/Localization/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.3 +// swift-tools-version: 6.3 import PackageDescription diff --git a/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift b/Packages/Localization/Sources/Methods/Negotiate.swift similarity index 64% rename from Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift rename to Packages/Localization/Sources/Methods/Negotiate.swift index c7e8422..06cfe99 100644 --- a/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift +++ b/Packages/Localization/Sources/Methods/Negotiate.swift @@ -1,43 +1,45 @@ import Foundation -import Localization /// Negotiates the best supported language for a request from its `Accept-Language` header. /// -/// Bound to the module's catalog languages via ``LanguageList``, an instance is invoked like a -/// function — through ``callAsFunction(forAcceptLanguage:)`` — to resolve a header value to a +/// 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. -struct NegotiateLanguage { +public struct Negotiate: 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 // MARK: Initializers - /// Creates a language negotiator backed by the module's String Catalog. - init() { - self.list = .init(bundle: .module) + /// Creates a language negotiator backed by the given bundle's String Catalog. + /// - Parameter bundle: the bundle whose String Catalog defines the supported languages. + public init( + bundle: Bundle + ) { + self.list = .init(bundle: bundle) } // MARK: Methods /// Picks the best supported language for the given `Accept-Language` header value. /// - /// Invoked by calling the instance directly, for example `negotiate(forAcceptLanguage: header)`. + /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. /// The header is split into its language tags (dropping any `q` weights), then matched against /// the supported languages with `Bundle.preferredLocalizations(from:forPreferences:)`. 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. - func callAsFunction( - forAcceptLanguage acceptLanguage: String? + public func callAsFunction( + acceptLanguage language: String? ) -> String { - guard let acceptLanguage else { + guard let language else { return list.default } - let tags = tags(from: acceptLanguage) + let tags = tags(from: language) guard !tags.isEmpty else { return list.default @@ -56,7 +58,7 @@ struct NegotiateLanguage { // MARK: - Helpers -private extension NegotiateLanguage { +private extension Negotiate { // MARK: Methods @@ -71,15 +73,29 @@ private extension NegotiateLanguage { from acceptLanguage: String ) -> [String] { acceptLanguage - .split(separator: ",") + .split(separator: .Separator.comma) .map { entry in entry - .split(separator: ";") + .split(separator: .Separator.semicolon) .first .map(String.init)? - .trimmingCharacters(in: .whitespaces) ?? "" + .trimmingCharacters(in: .whitespaces) + ?? .empty } .filter { !$0.isEmpty } } } + +// MARK: - Constants + +private extension Character { + enum Separator { + static let comma: Character = "," + static let semicolon: Character = ";" + } +} + +private extension String { + static let empty: String = "" +} diff --git a/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift new file mode 100644 index 0000000..4ab3160 --- /dev/null +++ b/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite("Negotiate method") +struct NegotiateTests { + + // MARK: Constants + + private let negotiate = Negotiate(bundle: .module) + + // MARK: Functional tests + + @Test + func `matches an exact language tag`() { + let language = negotiate(acceptLanguage: "de") + + #expect(language == "de") + } + + @Test + func `matches a regional language tag`() { + let language = negotiate(acceptLanguage: "de-AT") + + #expect(language == "de") + } + + @Test + func `respects the header order of preference`() { + let language = negotiate(acceptLanguage: "de, en") + + #expect(language == "de") + } + + @Test + func `strips quality weights from the language tags`() { + let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9") + + #expect(language == "de") + } + + @Test + func `trims whitespace around the language tags`() { + let language = negotiate(acceptLanguage: " de , en ") + + #expect(language == "de") + } + + @Test + func `falls back to the default for an unsupported language`() { + let language = negotiate(acceptLanguage: "fr") + + #expect(language == "en") + } + + @Test + func `falls back to the default for a missing header`() { + let language = negotiate(acceptLanguage: nil) + + #expect(language == "en") + } + + @Test + func `falls back to the default for an empty header`() { + let language = negotiate(acceptLanguage: "") + + #expect(language == "en") + } + + @Test + func `falls back to the default for a header without language tags`() { + let language = negotiate(acceptLanguage: " , ;q=0.5,") + + #expect(language == "en") + } + +} diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme index 24b3ac5..2700a30 100644 --- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -26,13 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> @@ -50,8 +45,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - useCustomWorkingDirectory = "YES" - customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website" + useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme new file mode 100644 index 0000000..d5af719 --- /dev/null +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 929c0d0..7e4b279 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.3 +// swift-tools-version: 6.3 import PackageDescription @@ -21,7 +21,7 @@ let package = Package( ], dependencies: [ .package( - path: "../Packages/Localization" + path: "../../Packages/Localization" ), .package( url: "https://github.com/elementary-swift/elementary.git", diff --git a/Services/Website/README.md b/Services/Website/README.md index 0e0bacd..614ab5e 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -3,9 +3,11 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi ## Overview The service: -- Serves the landing page at `GET /` (rendered once with [Elementary](https://github.com/elementary-swift/elementary) and cached). +- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached). +- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteCore` 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 health checks at `GET /health` with a static JSON payload. - Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`. -- Returns a custom HTML 404 page for any request that matches neither a route nor a static file. +- Returns a custom HTML 404 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. @@ -20,14 +22,18 @@ Two SwiftPM targets: | `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. | | `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. | +`WebsiteCore` also depends on the local `Localization` package (`Packages/Localization`), which provides the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages. + Requests pass through the middleware chain in this order (outermost first), then reach the routes: ``` LogRequestsMiddleware - → SecurityHeadersMiddleware (security headers on every response) - → ResponseCompressionMiddleware (gzip/deflate above the size threshold) - → NotFoundMiddleware (renders the 404 page on .notFound) - → FileMiddleware (serves Resources/Static) -RootController (GET / → landing page) + → 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 → health check) ``` ## Configuration @@ -40,7 +46,7 @@ the following sources, **highest precedence first**: ### Environment variable naming A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`, -`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` →` CACHE_MAX_AGE_TEXT`. +`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` → `CACHE_MAX_AGE_TEXT`. > To disable a header or override a value, leave the variable **unset** to fall back to the default. A > variable that is set but **blank** is treated as an explicit empty value, not as "use the default". @@ -92,7 +98,7 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080 swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug ``` -Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets`LOG_LEVEL=debug`: +Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`): ```sh make pkg-build # swift build make img-mount # docker compose up --build --detach @@ -100,16 +106,17 @@ make img-unmount # docker compose down + remove the local image ``` `make help` lists every available target. + ## Testing ```sh make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel ``` -Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The`Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and` WebsiteCoreTests` (the library unit tests). +Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteCoreTests` (the library unit tests). ## Deployment -The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`(`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`). +The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`). Build, tag, and push a release to the registry (an explicit version is required): ```sh make img-release version=1.2.3 diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift index 8493813..f9da28a 100644 --- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift +++ b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift @@ -10,7 +10,7 @@ import NIOCore /// 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. /// -/// ``LocalizedHTMLResponses`` builds on this type, caching one instance per supported language. +/// ``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. diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift index 195196c..0172f98 100644 --- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift @@ -1,5 +1,4 @@ import Hummingbird -import Localization /// A request context that carries the language negotiated for the request. /// @@ -34,9 +33,17 @@ public struct WebsiteRequestContext: LocalizedRequestContext { /// Creates a request context for the given source. /// - Parameter source: the source the context is initialized from. - public init(source: Source) { + public init( + source: Source, + ) { self.coreContext = .init(source: source) - self.language = LanguageList(bundle: .module).default + self.language = .empty } } + +// MARK: - Constants + +private extension String { + static let empty = "" +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift similarity index 61% rename from Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift rename to Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift index 4ebf3bb..b343f3c 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift @@ -2,9 +2,9 @@ import HTTPTypes extension HTTPField.Name { /// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`). - static let permissionsPolicy = Self("Permissions-Policy")! + public static let permissionsPolicy = Self("Permissions-Policy")! /// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`). - static let referrerPolicy = Self("Referrer-Policy")! + public 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")! + public static let frameOptions = Self("X-Frame-Options")! } diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift index 99725f5..db18270 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift +++ b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift @@ -15,13 +15,13 @@ public struct LocalizationMiddleware { // MARK: Properties /// Negotiates the request's language from its `Accept-Language` header. - private let negotiate: NegotiateLanguage + private let negotiate: Negotiate // MARK: Initializers - /// Creates a localization middleware. + /// Creates a localization middleware that negotiates against the module's String Catalog languages. public init() { - self.negotiate = .init() + self.negotiate = .init(bundle: .module) } } @@ -47,7 +47,7 @@ extension LocalizationMiddleware: RouterMiddleware { var context = context context.language = negotiate( - forAcceptLanguage: request.headers[.acceptLanguage] + acceptLanguage: request.headers[.acceptLanguage] ) return try await next(request, context)