From 79cfb42fbbadfe03886f78a95d8fb60b5c15b85c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 30 Aug 2026 19:03:52 +0200 Subject: [PATCH] Updated the LocalizationMiddleware middleware in the Infrastructure package to read explicit language requests. --- .../Middlewares/LocalizationMiddleware.swift | 26 +++++++++-- .../Sources/Public/Types/SocialCard.swift | 4 +- .../LocalizationMiddlewareTests.swift | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index 964909c..c7adec7 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -5,11 +5,13 @@ 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 `lang` query parameter, its path, and its +/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's +/// ``LocalizedRequestContext/language``. /// -/// The 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 query parameter is the deliberate override a language switcher links to; failing that, a leading path segment naming a supported language +/// pins it, so an unrouted path under a language's prefix — its not-found page — answers in that language. Values naming no supported language +/// are ignored, leaving the header. The request is passed through untouched — the path and routing are unaffected. public struct LocalizationMiddleware { // MARK: Properties @@ -49,7 +51,13 @@ extension LocalizationMiddleware: RouterMiddleware { ) async throws -> Response { var context = context + // The deliberate query override first; failing that, the leading path segment, so a language's whole URL + // prefix — routed or not — answers in its language. + let requested = request.uri.queryParameters[.Parameter.language].map(String.init) + ?? request.uri.path.split(separator: "/").first.map(String.init) + context.language = negotiate( + requested: requested, acceptLanguage: request.headers[.acceptLanguage] ) @@ -57,3 +65,13 @@ extension LocalizationMiddleware: RouterMiddleware { } } + +// MARK: - Constants + +private extension Substring { + /// A namespace for the query parameters the middleware reads. + enum Parameter { + /// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path. + static let language: Substring = "lang" + } +} diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift index dd7926e..0fe4a74 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -12,7 +12,7 @@ public struct SocialCard: Sendable { /// The locale of the card's text, or `nil` to omit its tag. /// - /// Open Graph specifies the `language_TERRITORY` form (e.g. `en_NL`); scrapers also accept a bare language code (e.g. `en`). + /// 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. @@ -41,7 +41,7 @@ public struct SocialCard: Sendable { /// - 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_NL`), or `nil` (the default) + /// - 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`. diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index f246f5c..bb167a9 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -25,6 +25,9 @@ struct LocalizationMiddlewareTests { router.get("language") { _, context in context.language } + router.get("de/language") { _, context in + context.language + } return router }()) @@ -69,4 +72,44 @@ struct LocalizationMiddlewareTests { } } + @Test + func `honours a supported lang query parameter over the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language?lang=de", + method: .get, + headers: [.acceptLanguage: "en"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + /// A language's whole URL prefix answers in its language, so its not-found page does too. + @Test + func `pins the language a leading path segment names over the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/de/language", + method: .get, + headers: [.acceptLanguage: "en"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + @Test + func `negotiates the header when the lang query parameter is unsupported`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language?lang=fr", + method: .get, + headers: [.acceptLanguage: "de"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + }