commit 22e737d9c290e46629477b362044921d15ee97ed Author: Javier Cicchelli Date: Wed Aug 19 22:55:00 2026 +0200 Initial commit. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b74369 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Build context is the repo root (see Services/Website/Dockerfile). Keep the +# context lean: exclude build artifacts, VCS, IDE cruft, secrets and anything +# not needed to compile the Swift package. + +# Version control +.git +**/.git + +# Swift / SPM build artifacts +**/.build +**/.swiftpm + +# Test sources +**/Tests/DB + +# Xcode project (not used by the Linux build) +*.xcodeproj + +# 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). +**/.env +**/.env.local + +# Compose / tooling / docs not needed inside the image +**/docker-compose.* +**/Makefile +**/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a140df4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# 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. + +# Docker / environment +.env +!.env.local + +## Local PostgreSQL data files +Services/Website/Tests/DB/ + +# 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..fbd0fa3 --- /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": false, + "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/CCN.xcodeproj/project.pbxproj b/CCN.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5091b24 --- /dev/null +++ b/CCN.xcodeproj/project.pbxproj @@ -0,0 +1,94 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + 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 = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXProject section */ + 02642FD12FEEB9A5007FA466 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + ORGANIZATIONNAME = "Röck+Cöde VoF"; + }; + buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "CCN" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 02642FD02FEEB9A5007FA466; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 110; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ); + }; +/* End PBXProject section */ + +/* Begin XCBuildConfiguration section */ + 02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "CCN" */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = 7FMNM89WKG; + }; + name = Debug; + }; + 02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "CCN" */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = 7FMNM89WKG; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "CCN" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "CCN" */, + 02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "CCN" */, + ); + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 02642FD12FEEB9A5007FA466 /* Project object */; + validationLevel = 1; +} diff --git a/CCN.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CCN.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/CCN.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme new file mode 100644 index 0000000..56914ab --- /dev/null +++ b/Packages/Infrastructure/.swiftpm/xcode/xcshareddata/xcschemes/Infrastructure.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift new file mode 100644 index 0000000..e399fba --- /dev/null +++ b/Packages/Infrastructure/Package.swift @@ -0,0 +1,68 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Infrastructure", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Infrastructure", + targets: [ + "Infrastructure" + ] + ), + ], + 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" + ), + ], + targets: [ + .target( + name: "Infrastructure", + dependencies: [ + .byName(name: "Localization"), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + ], + path: "Sources" + ), + .testTarget( + name: "InfrastructureTests", + dependencies: [ + .byName(name: "Infrastructure"), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + ], + 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..36494f9 --- /dev/null +++ b/Packages/Infrastructure/README.md @@ -0,0 +1,51 @@ +# 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 +| Role | Types | +| --- | --- | +| 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 `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 | +| Analytics | `Analytics`, the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets | +| 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, the `document:` closure building a page for a locale, and a `Page` conformer's `metadata` plus its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive fully formed and absolute; composing them stays with the page. +- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready representation — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — and `Page` applies it without knowing the vocabulary. Page ``scripts`` and the tracker render as `defer`red head tags, with a `preconnect` hint for the tracker's cross-origin host. +- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `*+Defaults` extensions are the pattern. The open schema.org vocabularies extend the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, and a service adds its own. +- **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 +│ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own 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 +├── Catalogs/ the String Catalog fixture, copied verbatim so it loads on Linux +└── Utils/ stubs (StubAsset, StubPage, …) and the suite Tag constants +``` + +## Testing +Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits. + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms). 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/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift new file mode 100644 index 0000000..4165586 --- /dev/null +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -0,0 +1,46 @@ +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/Builders/RouteCollectionBuilder.swift b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift new file mode 100644 index 0000000..a201c2e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Builders/RouteCollectionBuilder.swift @@ -0,0 +1,46 @@ +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. +@resultBuilder +public enum RouteCollectionBuilder { + + public static func buildExpression( + _ controller: some RouterController + ) -> [RouteCollection] { + [controller.routes] + } + + public static func buildBlock( + _ collections: [RouteCollection]... + ) -> [RouteCollection] { + collections.flatMap { $0 } + } + + public static func buildOptional( + _ collections: [RouteCollection]? + ) -> [RouteCollection] { + collections ?? [] + } + + public static func buildEither( + first collections: [RouteCollection] + ) -> [RouteCollection] { + collections + } + + public static func buildEither( + second collections: [RouteCollection] + ) -> [RouteCollection] { + collections + } + + public static func buildArray( + _ collections: [[RouteCollection]] + ) -> [RouteCollection] { + collections.flatMap { $0 } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift new file mode 100644 index 0000000..bdcd25e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -0,0 +1,52 @@ +/// 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 folder within the static root that holds files with this extension, if any. + var folder: String? { + switch self { + case .css: "css" + case .js: "js" + default: nil + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift new file mode 100644 index 0000000..9f89f0a --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -0,0 +1,12 @@ +import HTTPTypes + +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/RouterMethods+RouteCollections.swift b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift new file mode 100644 index 0000000..25c55f2 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/RouterMethods+RouteCollections.swift @@ -0,0 +1,32 @@ +import Hummingbird + +public extension RouterMethods { + + // MARK: Methods + + /// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder. + /// + /// Mirrors `addMiddleware`, letting controllers be listed declaratively: + /// + /// ```swift + /// router.addController { + /// RootController() + /// HealthController() + /// } + /// ``` + /// + /// 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 + func addController( + @RouteCollectionBuilder _ build: () -> [RouteCollection] + ) -> Self { + for collection in build() { + addRoutes(collection) + } + + return self + } + +} 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..2791b1e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -0,0 +1,22 @@ +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..70a2097 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -0,0 +1,83 @@ +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/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift new file mode 100644 index 0000000..964909c --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -0,0 +1,59 @@ +import Foundation +import HTTPTypes +import Hummingbird +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``. +/// +/// 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 + + /// Negotiates the request's language from its `Accept-Language` header. + private let negotiate: Negotiate + + // MARK: Initializers + + /// 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) + } + +} + +// MARK: - RouterMiddleware + +extension LocalizationMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Negotiates the request's language and records it on the context before passing it down. + /// - 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. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + var context = context + + context.language = negotiate( + acceptLanguage: request.headers[.acceptLanguage] + ) + + return try await next(request, context) + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift new file mode 100644 index 0000000..b5f51e6 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -0,0 +1,74 @@ +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 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 + + /// The error page, rendered once per supported language and reused for every not-found response. + private let responses: LocalizedHTMLCollectionResponse + + // MARK: Initializers + + /// Creates a not-found middleware. + /// - 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( + bundle: bundle, + status: .notFound, + document: document + ) + } + +} + +// MARK: - RouterMiddleware + +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 error page with a `404 Not Found` status. + /// - Throws: any non-not-found error thrown downstream. + 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 { + guard + let responseError = error as? any HTTPResponseError, + responseError.status == .notFound + else { + throw error + } + + return responses.response( + 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/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift new file mode 100644 index 0000000..8658e8c --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -0,0 +1,154 @@ +import HTTPTypes +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. +/// +/// 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 + + /// The precomputed headers applied to every response. + private let fields: HTTPFields + + // 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``). + public init( + configuration: Configuration = .init() + ) { + self.fields = configuration.fields + } + +} + +// MARK: - RouterMiddleware + +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. + /// + /// 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. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response with the security headers applied. + /// - 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: 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 + } + + return response + } + +} + +// MARK: - Helpers + +private extension SecurityHeadersMiddleware.Configuration { + + // MARK: Computed + + /// The configuration expressed as the headers to apply, omitting any whose value is `nil`. + var fields: HTTPFields { + var fields = HTTPFields() + + fields[.contentSecurityPolicy] = contentSecurityPolicy + fields[.xContentTypeOptions] = contentTypeOptions + fields[.frameOptions] = frameOptions + fields[.referrerPolicy] = referrerPolicy + fields[.permissionsPolicy] = permissionsPolicy + fields[.strictTransportSecurity] = strictTransportSecurity + + return fields + } + +} + +// MARK: - 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. + public struct Configuration: Sendable { + + // MARK: Properties + + /// The `Content-Security-Policy` value (controls which sources the browser will load). + public let contentSecurityPolicy: String? + /// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`). + public let contentTypeOptions: String? + /// The `X-Frame-Options` value (controls whether the page may be framed). + public let frameOptions: String? + /// The `Referrer-Policy` value (controls how much referrer information is shared). + public let referrerPolicy: String? + /// The `Permissions-Policy` value (gates access to powerful browser features). + public let permissionsPolicy: String? + /// The `Strict-Transport-Security` value, or `nil` to omit the header (the default). + public let strictTransportSecurity: String? + + // MARK: Initializers + + /// 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. + /// - Parameters: + /// - contentSecurityPolicy: the `Content-Security-Policy` value. + /// - contentTypeOptions: the `X-Content-Type-Options` value. + /// - frameOptions: the `X-Frame-Options` value. + /// - referrerPolicy: the `Referrer-Policy` value. + /// - permissionsPolicy: the `Permissions-Policy` value. + /// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it. + public init( + contentSecurityPolicy: String? = String.Security.contentSecurityPolicy, + contentTypeOptions: String? = String.Security.contentTypeOptions, + frameOptions: String? = String.Security.frameOptions, + referrerPolicy: String? = String.Security.referrerPolicy, + permissionsPolicy: String? = String.Security.permissionsPolicy, + strictTransportSecurity: String? = nil + ) { + self.contentSecurityPolicy = contentSecurityPolicy + self.contentTypeOptions = contentTypeOptions + self.frameOptions = frameOptions + self.referrerPolicy = referrerPolicy + self.permissionsPolicy = permissionsPolicy + self.strictTransportSecurity = strictTransportSecurity + } + + } +} 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..32173a9 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -0,0 +1,87 @@ +/// 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. Each file lands in its extension's own +/// folder unless the asset names a ``folder`` of its own. +public protocol Asset: Sendable { + + // MARK: Properties + + /// The folder within the static files root that holds the asset's files, or `nil` (the default) to use each extension's own folder. + var folder: String? { get } + + /// 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: Computed + + /// The asset's files live in each extension's own folder by default. + var folder: String? { nil } + + // 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 (folder ?? fileExtension.folder) + .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..50dcb35 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -0,0 +1,14 @@ +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..a2f4d6a --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -0,0 +1,207 @@ +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, the summary, canonical, and social card tags, the structured data script, the analytics +/// 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 + + /// 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 analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it. + var analytics: Analytics? { get } + + /// 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 as the document body. + @HTMLBuilder + var content: Content { get } + + /// The locale the page content is localized to. + var locale: Locale { get } + + /// 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 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) + /// 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 } + + /// 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``; the ``scripts`` load deferred from the ``head``. + @HTMLBuilder + var body: some HTML { + content + } + + /// 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. + /// + /// 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. The analytics tracker, by contrast, is an executable script the + /// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled. + /// When recorder mode is on, the session recorder script follows the tracker script, deferred as well and carrying only the website id. + @HTMLBuilder + var head: some HTML { + meta( + .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), + .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) + ) + } + } + + if let structuredData { + script(.custom( + name: "type", + value: "application/ld+json" + )) { + HTMLRaw(structuredData.payload) + } + } + + if let analytics { + script( + .defer, + .src(analytics.scriptURL) + ) {} + .attributes(contentsOf: analytics.attributes.map { + .custom( + name: $0.name, + value: $0.value + ) + }) + + if let recorderScriptURL = analytics.recorderScriptURL { + script( + .defer, + .src(recorderScriptURL), + .custom( + name: "data-website-id", + value: analytics.websiteID + ) + ) {} + } + } + + metadata + + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath( + for: .css, + version: assetVersion + )) + ) + } + + for file in scripts { + script( + .defer, + .src(file.urlPath( + for: .js, + version: assetVersion + )) + ) {} + } + } + + /// The analytics tracker is omitted unless the page provides one. + var analytics: Analytics? { + nil + } + + /// The social card is omitted unless the page provides one. + var socialCard: SocialCard? { + 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/Protocols/RouterController.swift b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift new file mode 100644 index 0000000..ae1ca19 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/RouterController.swift @@ -0,0 +1,32 @@ +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(_:)``: +/// +/// ```swift +/// struct HealthController: RouterController { +/// var routes: RouteCollection { +/// RouteCollection(context: Context.self) +/// .get("health") { _, _ in HTTPResponse.Status.ok } +/// } +/// } +/// +/// router.addController { +/// HealthController() +/// } +/// ``` +public protocol RouterController: Sendable { + + // MARK: Associated types + + /// The request context the controller's routes operate on. + associatedtype Context: RequestContext + + // MARK: Properties + + /// The collection of routes the controller exposes. + var routes: RouteCollection { get } + +} 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/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift new file mode 100644 index 0000000..b497105 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -0,0 +1,75 @@ +import Elementary +import Foundation +import HTTPTypes +import Hummingbird +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. +public struct LocalizedHTMLCollectionResponse: Sendable { + + // MARK: Properties + + /// 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. + private let responses: [String: CachedHTMLResponse] + + // MARK: Initializers + + /// 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. + public init( + bundle: Bundle, + status: HTTPResponse.Status = .ok, + document: (Locale) -> Document + ) { + self.list = .init(bundle: bundle) + self.responses = list.all + .reduce(into: [:]) { responses, language in + responses[language] = CachedHTMLResponse( + status: status, + additionalHeaders: [ + .contentLanguage: language, + .vary: "Accept-Language", + ], + document: document(.init( + identifier: language + )) + ) + } + } + + // MARK: Methods + + /// Builds the response for the given language, falling back to the default language. + /// - 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. + public func response( + for language: String, + request: Request + ) -> Response { + guard + let response = responses[language] ?? responses[list.default] + else { + return .init( + status: .internalServerError + ) + } + + return response.response( + for: request + ) + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift new file mode 100644 index 0000000..5a9f321 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -0,0 +1,115 @@ +import Foundation + +/// The Umami analytics tracker a page embeds as a deferred `"#)) + #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 deferred in the head, after the stylesheets`() throws { + let html = StubPage().render() + + 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")) + + // 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 + 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 `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 `omits the analytics tracker by default`() { + let html = StubPage().render() + + #expect(!html.contains("data-website-id")) + #expect(!html.contains(#"rel="preconnect""#)) + } + + @Test + func `renders the analytics tracker when provided`() throws { + let html = StubPage(analytics: .init( + scriptURL: "https://analytics.example.com/script", + websiteID: "0000-website-id", + domains: "example.com" + )).render() + + #expect(html.contains(#""#)) + + // 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: #""#)) + #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() + + #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/Cases/Public/Types/Analytics/AnalyticsEventTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift new file mode 100644 index 0000000..6256ac6 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift @@ -0,0 +1,105 @@ +import Testing + +@testable import Infrastructure + +@Suite( + "Analytics.Event type", + .tags(.type) +) +struct AnalyticsEventTests { + + // MARK: Computed tests + + @Test + func `renders the event name alone when it carries no properties`() { + let event = Analytics.Event(name: "instagram") + + #expect(event.attributes.count == 1) + #expect(event.attributes[0] == Analytics.Attribute( + "data-umami-event", + value: "instagram" + )) + } + + @Test + func `renders each property as an attribute suffixed by its key`() { + let event = Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + ) + + #expect(event.attributes.count == 2) + #expect(event.attributes[1] == Analytics.Attribute( + "data-umami-event-set", + value: "avc-xi" + )) + } + + @Test + func `renders the name first, then the properties in the order given`() { + let event = Analytics.Event( + name: "interview", + properties: [ + "placement": "credit", + "locale": "en", + ] + ) + + // The order is the rendered attribute order, which a dictionary would leave to churn between builds. + #expect(event.attributes.map(\.name) == [ + "data-umami-event", + "data-umami-event-placement", + "data-umami-event-locale", + ]) + #expect(event.attributes.map(\.value) == ["interview", "credit", "en"]) + } + + // MARK: Equatable tests + + @Test + func `matches an event rendering the same attributes`() { + let event = Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + ) + + #expect(event == Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + )) + } + + @Test(arguments: [ + Analytics.Event( + name: "playlist", + properties: ["set": "avc-ix"] + ), + Analytics.Event( + name: "playlist", + properties: ["show": "avc-xi"] + ), + Analytics.Event( + name: "apple_music", + properties: ["set": "avc-xi"] + ), + Analytics.Event(name: "playlist"), + Analytics.Event( + name: "playlist", + properties: [ + "set": "avc-xi", + "placement": "strip", + ] + ), + ]) + func `differs from an event rendering anything else`( + from other: Analytics.Event + ) { + let event = Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + ) + + #expect(event != other) + } + +} 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..39e297c --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift @@ -0,0 +1,135 @@ +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 `derives its origin from the script URL`() { + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "example.com" + ) + + #expect(analytics.origin == "https://analytics.example.com") + } + + @Test + func `keeps an explicit port in its origin`() { + let analytics = Analytics( + scriptURL: "http://localhost:3000/script", + websiteID: "id-123", + domains: "localhost" + ) + + #expect(analytics.origin == "http://localhost:3000") + } + + @Test + func `carries no origin for a script URL without a scheme or host`() { + let analytics = Analytics( + scriptURL: "/script", + websiteID: "id-123", + domains: "example.com" + ) + + #expect(analytics.origin == nil) + } + + @Test + func `carries no recorder script URL by default`() { + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "example.com" + ) + + #expect(analytics.recorderScriptURL == nil) + } + + @Test + func `derives its recorder script URL from the origin when recorder mode is on`() { + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "example.com", + recorder: true + ) + + #expect(analytics.recorderScriptURL == "https://analytics.example.com/recorder.js") + } + + @Test + func `carries no recorder script URL when no origin can be derived`() { + let analytics = Analytics( + scriptURL: "/script", + websiteID: "id-123", + domains: "example.com", + recorder: true + ) + + #expect(analytics.recorderScriptURL == nil) + } + + @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", + ]) + } + + @Test + func `omits the domains filter when it is empty`() { + // The tracker reads the attribute as an allowlist, so rendering it empty would filter out every host rather than none. + let analytics = Analytics( + scriptURL: "https://analytics.example.com/script", + websiteID: "id-123", + domains: "" + ) + + #expect(!analytics.attributes.map(\.name).contains("data-domains")) + } + +} 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..6849302 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -0,0 +1,91 @@ +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("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), + ]) + } + + @Test + func `omits the tags of the facts a minimal card does not carry`() { + let card = SocialCard(title: "A Title") + + #expect(card.tags == [ + .init("website", name: .type), + .init("A Title", name: .title), + .init("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("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) + } + +} 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/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..9000a1c --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift @@ -0,0 +1,12 @@ +import Infrastructure + +/// An ``Asset`` with a fixed file name and set of extensions, optionally held in a folder of its own. +struct StubAsset: Asset { + + // MARK: Properties + + var folder: String? = nil + 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/Controllers/StubController.swift b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift new file mode 100644 index 0000000..48e2e24 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Controllers/StubController.swift @@ -0,0 +1,30 @@ +import Hummingbird +import Infrastructure + +/// A controller serving its path back as plain text, used to observe route registration. +struct StubController { + + // MARK: Properties + + /// The path the controller serves, also returned as the response body. + let path: String + +} + +// MARK: - RouterController + +extension StubController: RouterController { + + // MARK: Properties + + var routes: RouteCollection { + let routes = RouteCollection(context: BasicRequestContext.self) + + routes.get(.init(path)) { _, _ in + self.path + } + + return routes + } + +} 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..1117cd8 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +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 a protocol scaffolding of the Infrastructure package. + @Tag static var `protocol`: Tag + /// 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 new file mode 100644 index 0000000..d719f1d --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -0,0 +1,101 @@ +import Elementary +import Foundation +import Infrastructure + +/// A ``Page`` with fixed content, metadata, and stub assets. +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? + + /// 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 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? + + // 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. + /// - 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 + /// head, or `nil` (the default) to omit it. + /// - summary: the summary rendered in the document head, or `nil` (the default) + /// to omit it. + init( + 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 + self.socialCard = socialCard + self.structuredData = structuredData + self.summary = summary + } + + // 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/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme b/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme new file mode 100644 index 0000000..c25c6ee --- /dev/null +++ b/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift new file mode 100644 index 0000000..8778c92 --- /dev/null +++ b/Packages/Localization/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Localization", + defaultLocalization: "en", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Localization", + targets: [ + "Localization" + ] + ) + ], + targets: [ + .target( + name: "Localization", + path: "Sources", + ), + .testTarget( + name: "LocalizationTests", + dependencies: [ + .byName(name: "Localization") + ], + 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/Localization/README.md b/Packages/Localization/README.md new file mode 100644 index 0000000..5128cc4 --- /dev/null +++ b/Packages/Localization/README.md @@ -0,0 +1,43 @@ +# 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 +| 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`, so adding a language is a translation-only 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 resource verbatim (never `.process` it) so it ships as raw JSON everywhere. Only simple `stringUnit` values are decoded — plural and device variations are not. +- **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` at startup and warn when it is not `.loaded`. +- **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 for the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits. + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling 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 new file mode 100644 index 0000000..9a30292 --- /dev/null +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -0,0 +1,38 @@ +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. +/// +/// 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. + 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 } + + // MARK: Methods + + /// Resolves a catalog key in the given locale. + /// - Parameters: + /// - key: the catalog key to look up. + /// - locale: the locale to resolve the key in. + /// - Returns: the localized string, falling back to the source language, then to the key itself. + func string( + for key: String, + in locale: Locale + ) -> String + +} diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift new file mode 100644 index 0000000..b4c203d --- /dev/null +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -0,0 +1,16 @@ +/// 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 new file mode 100644 index 0000000..de68dbc --- /dev/null +++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift @@ -0,0 +1,232 @@ +import Foundation +import Synchronization + +/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources. +/// +/// The catalog is parsed from raw JSON rather than through Foundation's compiled-catalog APIs (`String(localized:)`, +/// `Bundle.localizations`, `Bundle.preferredLocalizations`). Those are either unavailable or non-functional on non-Darwin platforms +/// (Linux), where the toolchain ships no `xcstringstool` and so copies the raw `.xcstrings` into the resource bundle instead of compiling it. +/// Reading the catalog ourselves gives identical behaviour on every platform the service builds for. +/// +/// Only simple `stringUnit` values are decoded; plural and device variations are not represented. +struct StringCatalog: Sendable { + + // MARK: Properties + + /// The source language of the catalog, used as the fallback when a key lacks a requested localization. + let sourceLanguage: String + + /// 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. + var languages: Set { + var languages = Set(entries.values.flatMap(\.keys)) + + languages.insert(sourceLanguage) + + return languages + } + + // MARK: Initializers + + /// Reads the catalog named `table` from `bundle`. + /// + /// Falls back to an empty catalog (source language `"en"`, no entries) when the resource is missing or cannot be decoded, so lookups degrade + /// to returning the key and the language list to the default. + /// - Parameters: + /// - bundle: the bundle whose resources contain the String Catalog. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. + init( + bundle: Bundle, + table: String = "Localizable" + ) { + guard let url = bundle.url( + forResource: table, + withExtension: .Extension.stringCatalog + ) else { + self.sourceLanguage = .Default.sourceLanguage + self.entries = [:] + self.state = .missing + return + } + + do { + let decoder = JSONDecoder() + let data = try Data(contentsOf: url) + let decoded = try decoder.decode( + Decoded.self, + from: data + ) + + self.sourceLanguage = decoded.sourceLanguage + self.entries = decoded.strings + .mapValues { entry in + (entry.localizations ?? [:]) + .compactMapValues { $0.stringUnit?.value } + } + self.state = .loaded + } catch { + 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 + } + } + +} + +// MARK: - CatalogResolving + +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 + ) -> String { + guard let byLanguage = entries[key] else { + return key + } + + for tag in locale.catalogTags { + if let match = byLanguage.first( + where: { $0.key.lowercased() == tag } + ) { + return match.value + } + } + + 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 + } + +} + +// MARK: - Decoding Types + +private extension StringCatalog { + + /// The subset of the `.xcstrings` format needed to resolve simple string entries. + struct Decoded: Decodable { + + let sourceLanguage: String + let strings: [String: Entry] + + struct Entry: Decodable { + let localizations: [String: Localization]? + } + + struct Localization: Decodable { + let stringUnit: StringUnit? + } + + struct StringUnit: Decodable { + let value: String + } + + } + +} + +// 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..56d3ceb --- /dev/null +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -0,0 +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. +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 new file mode 100644 index 0000000..b45e264 --- /dev/null +++ b/Packages/Localization/Sources/Public/Methods/Localize.swift @@ -0,0 +1,69 @@ +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. +public struct Localize: Sendable { + + // MARK: Properties + + /// 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. + /// - Parameters: + /// - bundle: the bundle whose String Catalog contains the keys to resolve. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. + public init( + bundle: Bundle, + table: String = "Localizable" + ) { + self.init(resolver: StringCatalog.cached( + bundle: bundle, + table: table + )) + } + + /// Creates a localizer backed by the given resolver. + /// + /// The seam for tests and alternative backends; the public API resolves against a bundled catalog. + /// - Parameter resolver: the backend that resolves keys to localized strings. + init( + resolver: any CatalogResolving + ) { + self.resolver = resolver + } + + // MARK: Methods + + /// Resolves a catalog key in the given locale. + /// + /// Invoked by calling the instance directly, for example `localize("index.title", locale: locale)`. + /// - 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. + public func callAsFunction( + _ key: String, + locale: Locale + ) -> String { + resolver.string( + for: key, + in: locale + ) + } + +} diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift new file mode 100644 index 0000000..ee41bed --- /dev/null +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -0,0 +1,198 @@ +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. +public struct Negotiate: Sendable { + + // MARK: Properties + + /// 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 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(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. + /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. + /// - Returns: the identifier of the supported language to serve. + public func callAsFunction( + acceptLanguage language: String? + ) -> String { + guard let language else { + return list.default + } + + let ranges = ranges(from: language) + + guard !ranges.isEmpty else { + return list.default + } + + let supported = list.all + + for range in ranges { + if range.tag == .wildcard { + return list.default + } + + if let match = match(range.tag, in: supported) { + return match + } + } + + return list.default + } + +} + +// MARK: - Helpers + +private extension Negotiate { + + // MARK: Methods + + /// 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. + /// - Parameter acceptLanguage: the raw `Accept-Language` header value. + /// - Returns: the language ranges, most preferred first. + func ranges( + from acceptLanguage: String + ) -> [LanguageRange] { + acceptLanguage + .split(separator: .Separator.comma) + .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 + } + .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. + /// + /// 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. + /// - Returns: the matching supported language, or `nil` when the tag matches none. + func match( + _ tag: String, + in supported: [String] + ) -> String? { + let tag = tag.lowercased() + + if let exact = supported.first( + where: { $0.lowercased() == tag } + ) { + return exact + } + + let primary = tag.primarySubtag + + return supported.first { + $0.lowercased().primarySubtag == primary + } + } + +} + +// 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 { + split(separator: .Separator.dash) + .first + .map(String.init) + ?? self + } +} diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift new file mode 100644 index 0000000..d3b666c --- /dev/null +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -0,0 +1,61 @@ +import Foundation + +/// The list of languages an app can serve, derived from a bundle's String Catalog. +/// +/// The languages are read from the injected `bundle`'s String Catalog, so the catalog that ships in that bundle is the single source of truth: adding +/// a language is a translation-only change — once a locale exists in the catalog, it appears in ``all`` with no code change required. +public struct LanguageList: Sendable { + + // MARK: Properties + + /// The backend that reports the available languages. + private let resolver: any CatalogResolving + + // MARK: Initializers + + /// Creates a language list backed by the given bundle. + /// - Parameter bundle: the bundle whose String Catalog defines the available languages. + public init( + bundle: Bundle + ) { + 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. + /// - Parameter resolver: the backend that reports the available languages. + init( + resolver: any CatalogResolving + ) { + self.resolver = resolver + } + + // 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. + /// The result is sorted for a stable order. + public var all: [String] { + resolver + .languages + .filter { $0 != "Base" } + .sorted() + } + +} diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift new file mode 100644 index 0000000..3ef1c67 --- /dev/null +++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite( + "Localize method", + .tags(.method) +) +struct LocalizeTests { + + // MARK: Constants + + private let localize = Localize(bundle: .module) + + // MARK: Functional tests + + @Test + func `resolves a key in the default locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "en") + ) + + #expect(text == "Hello") + } + + @Test + func `resolves a key in another locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "de"), + ) + + #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( + "unknown.key", + locale: Locale(identifier: "en"), + ) + + #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 new file mode 100644 index 0000000..f7cb0bc --- /dev/null +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite( + "Negotiate method", + .tags(.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 `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") + + #expect(language == "de") + } + + @Test + 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 ") + + #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/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift new file mode 100644 index 0000000..0c3829b --- /dev/null +++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite( + "LanguageList type", + .tags(.type) +) +struct LanguageListTests { + + // MARK: Properties tests + + @Suite("default") + struct Default { + @Test + func `defaults to english`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.default == "en") + } + + @Test + func `follows the catalog's source language`() { + let list = LanguageList( + 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 + func `lists the catalog languages`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.all.contains("en")) + #expect(list.all.contains("de")) + } + + @Test + func `excludes the base localization`() { + let list = LanguageList( + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["Base", "de", "en"] + ) + ) + + #expect(list.all == ["de", "en"]) + } + + @Test + func `sorts the languages`() { + let list = LanguageList( + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["fr", "en", "de"] + ) + ) + + #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 new file mode 100644 index 0000000..7135307 --- /dev/null +++ b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings @@ -0,0 +1,29 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "test.greeting" : { + "comment" : "Fixture string used by the Localization test suite.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hallo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Olá" + } + } + } + } + }, + "version" : "1.0" +} 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/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/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme new file mode 100644 index 0000000..1bd174b --- /dev/null +++ b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift new file mode 100644 index 0000000..85dba7c --- /dev/null +++ b/Packages/Persistence/Package.swift @@ -0,0 +1,99 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Persistence", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Persistence", + targets: [ + "Persistence" + ] + ) + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird-fluent.git", + from: "2.0.0" + ), + .package( + url: "https://github.com/vapor/fluent-postgres-driver.git", + from: "2.12.0" + ), + .package( + url: "https://github.com/vapor/fluent-sqlite-driver.git", + from: "4.9.0" + ), + .package( + url: "https://github.com/vapor/sql-kit.git", + from: "3.36.0" + ), + .package( + url: "https://github.com/vapor/postgres-nio.git", + from: "1.33.0" + ), + .package( + url: "https://github.com/apple/swift-nio.git", + from: "2.81.0" + ), + .package( + url: "https://github.com/apple/swift-nio-ssl.git", + from: "2.25.0" + ), + ], + targets: [ + .target( + name: "Persistence", + dependencies: [ + .product( + name: "HummingbirdFluent", + package: "hummingbird-fluent" + ), + .product( + name: "FluentPostgresDriver", + package: "fluent-postgres-driver" + ), + .product( + name: "FluentSQLiteDriver", + package: "fluent-sqlite-driver" + ), + .product( + name: "SQLKit", + package: "sql-kit" + ), + .product( + name: "PostgresNIO", + package: "postgres-nio" + ), + .product( + name: "NIOSSL", + package: "swift-nio-ssl" + ), + ], + path: "Sources" + ), + .testTarget( + name: "PersistenceTests", + dependencies: [ + .byName(name: "Persistence"), + .product( + name: "PostgresNIO", + package: "postgres-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..c3cb5e8 --- /dev/null +++ b/Packages/Persistence/README.md @@ -0,0 +1,56 @@ +# Persistence +The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe. + +## Overview +| Role | Types | +| --- | --- | +| Backend selection | `Driver` (`postgres` or `inMemory`), `Configuration` (the PostgreSQL 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` within a deadline | +| 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. The Website service's `ConfigReader+Properties` has 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` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend migrates on startup, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race. +- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Example`) instead. +- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint. +- **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 instead of each pooled connection getting a 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 driver itself enforces both TLS postures — `prefer` upgrades only when the server advertises TLS and continues in plaintext otherwise, `require` refuses a server that offers none. Tests pin both against a fake plaintext-only server. + +## 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 and silent fake PostgreSQL servers, and the suite Tag constants +``` + +## Testing +The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found: +```sh +swift test # in-memory only +POSTGRES_TEST_HOST=127.0.0.1 swift test # against the local PostgreSQL (make db-mount) +``` + +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` around `fluent.shutdown()` is the shape to follow. + +Every suite carries a tag for the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits. + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms). +- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server. diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift new file mode 100644 index 0000000..ef06d99 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift @@ -0,0 +1,28 @@ +import FluentKit + +/// Creates and drops the `example_records` table backing ``ExampleRecord``. +/// +/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a +/// domain model is defined. Migrations are append-only in production — add a new migration to alter the +/// schema rather than editing one that has already run. +struct CreateExampleRecord: AsyncMigration { + + // MARK: Methods + + /// Creates the `example_records` table with an `id` primary key and a required `name` column. + /// - Parameter database: the database the schema change is applied to. + func prepare(on database: Database) async throws { + try await database.schema(ExampleRecord.schema) + .id() + .field("name", .string, .required) + .create() + } + + /// Drops the `example_records` table, reverting ``prepare(on:)``. + /// - Parameter database: the database the schema change is applied to. + func revert(on database: Database) async throws { + try await database.schema(ExampleRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift new file mode 100644 index 0000000..350a8b1 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift @@ -0,0 +1,45 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `example_records` row. +/// +/// This is reference scaffolding: it demonstrates the model → migration → repository pattern the rest of +/// the package is built around, and is what the tests exercise. Replace it with the first real domain model +/// (paired with its own migration and repository) once one is defined. +/// +/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never +/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the +/// conformance is declared `@unchecked Sendable`. +final class ExampleRecord: Model, @unchecked Sendable { + + // MARK: Properties + + /// The name of the backing table. + static let schema = "example_records" + + /// The row's primary key, assigned on first save. + @ID(key: .id) + var id: UUID? + + /// The row's name column. + @Field(key: "name") + var name: String + + // MARK: Initializers + + /// Creates an empty record, as required by FluentKit to hydrate query results. + init() {} + + /// Creates a record with the given values. + /// - Parameters: + /// - id: the primary key, or `nil` to have one assigned on save. + /// - name: the value of the name column. + init( + id: UUID? = nil, + name: String + ) { + self.id = id + self.name = name + } + +} diff --git a/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift new file mode 100644 index 0000000..081dea1 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift @@ -0,0 +1,63 @@ +import FluentKit +import Foundation +import HummingbirdFluent + +/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries. +/// +/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference +/// types that must not escape the database's execution context. +public struct Example: Sendable, Equatable { + + // MARK: Properties + + /// The record's primary key, or `nil` if it has never been saved. + public let id: UUID? + /// The record's name. + public let name: String + +} + +/// Reads and writes ``ExampleRecord`` rows through the default database. +/// +/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the +/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning — so no +/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``; +/// replace it with the first real repository once a domain model is defined. +public struct ExampleRepository: Sendable { + + // MARK: Properties + + /// The service providing the default database the repository reads and writes through. + private let fluent: Fluent + + // MARK: Initializers + + /// Creates a repository backed by the given `Fluent` service. + /// - Parameter fluent: the service whose default database the repository operates on. + public init(fluent: Fluent) { + self.fluent = fluent + } + + // MARK: Methods + + /// Inserts a record with the given name. + /// - Parameter name: the name of the record to insert. + /// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier. + public func create(name: String) async throws -> Example { + let record = ExampleRecord(name: name) + + try await record.save(on: fluent.db()) + + return Example(id: record.id, name: record.name) + } + + /// Fetches every record, ordered by name. + /// - Returns: a `Sendable` snapshot of each record, sorted by name. + public func all() async throws -> [Example] { + try await ExampleRecord.query(on: fluent.db()) + .sort(\.$name) + .all() + .map { Example(id: $0.id, name: $0.name) } + } + +} diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift new file mode 100644 index 0000000..a86c51b --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift @@ -0,0 +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. +public enum Driver: Sendable { + + /// A PostgreSQL server, reached with the given connection parameters. + /// + /// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the connection is opened with. + case postgres(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. + case inMemory + +} diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift new file mode 100644 index 0000000..faf5777 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift @@ -0,0 +1,41 @@ +import NIOSSL +import PostgresNIO + +/// 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 PostgreSQL driver +/// receives the resulting connection TLS mode through ``postgresTLS()``. +public enum TLS: Sendable { + + /// Connect without TLS, in plaintext. + case off + + /// Connect over TLS when the server offers it, falling back to plaintext otherwise. + case prefer + + /// Connect only over TLS, refusing the connection when the server offers none. + case require + +} + +// MARK: - Methods + +extension TLS { + + /// The connection TLS mode passed to the PostgreSQL driver for this posture. + /// + /// Returns `.disable` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``. The driver enforces + /// both semantics natively: `prefer` upgrades to TLS only when the server advertises support and continues in plaintext otherwise, while `require` + /// refuses the connection when the server offers no TLS. + /// + /// - Throws: an error when the TLS context cannot be built from the default client configuration. + /// - Returns: the connection TLS mode for this posture. + func postgresTLS() throws -> PostgresConnection.Configuration.TLS { + switch self { + case .off: .disable + case .prefer: .prefer(try NIOSSLContext(configuration: .makeClientConfiguration())) + case .require: .require(try NIOSSLContext(configuration: .makeClientConfiguration())) + } + } + +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift new file mode 100644 index 0000000..932216e --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -0,0 +1,29 @@ +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: Sendable { + + // MARK: Initializers + + /// Creates a registrar for the migrations for a `Fluent` service. + public init() {} + + // MARK: Methods + + /// 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. + public func callAsFunction( + for fluent: Fluent + ) async { + await fluent.migrations.add([ + CreateExampleRecord() + ]) + } + +} diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift new file mode 100644 index 0000000..9282a5f --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift @@ -0,0 +1,77 @@ +import HummingbirdFluent +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()`. +public struct Probe: Sendable { + + // MARK: Properties + + /// The `Fluent` service whose default database is probed. + private let fluent: Fluent + + /// The longest the probe waits for the database's answer before reporting it as not reachable. + private let timeout: Duration + + // MARK: Initializers + + /// Creates a probe for the default database of the given `Fluent` service. + /// - Parameters: + /// - fluent: the `Fluent` service whose default database is probed. + /// - timeout: the longest the probe waits for the database's answer before reporting it as not reachable. + public init( + fluent: Fluent, + timeout: Duration = .seconds(2) + ) { + self.fluent = fluent + self.timeout = timeout + } + + // MARK: Methods + + /// Reports whether the database behind the `Fluent` service is reachable. + /// + /// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the PostgreSQL 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, and an answer that does not arrive within the timeout is likewise reported as not reachable — so + /// a database that hangs yields a prompt "not ready" instead of a hanging readiness endpoint. A default database that is not an SQL database is + /// also reported as not reachable. + /// - Returns: `true` when the database answers the probe in time, `false` otherwise. + public func callAsFunction() async -> Bool { + guard let database = fluent.db() as? any SQLDatabase else { + return false + } + + // The query is raced against the deadline from unstructured tasks whose first answer wins: a structured group + // would await the query child before returning, so a database that hangs mid-query — the very failure the + // deadline exists for — would hang the probe with it. The loser is cancelled and abandoned; a late answer lands + // in a finished stream and is dropped. + let (answers, continuation) = AsyncStream.makeStream(of: Bool.self) + let query = Task { + do { + try await database + .raw("SELECT 1") + .run() + + continuation.yield(true) + } catch { + continuation.yield(false) + } + } + let deadline = Task { + try? await Task.sleep(for: timeout) + + continuation.yield(false) + } + + var answer = answers.makeAsyncIterator() + let isReachable = await answer.next() ?? false + + query.cancel() + deadline.cancel() + + return isReachable + } +} diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift new file mode 100644 index 0000000..51cad79 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/Service.swift @@ -0,0 +1,99 @@ +import FluentPostgresDriver +import FluentSQLiteDriver +import HummingbirdFluent +import Logging +import PostgresNIO + +/// 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()`. +public struct Service: Sendable { + + // MARK: Enumerations + + /// The persistence backend resolved at construction, with the PostgreSQL TLS mode already built. + private enum Backend { + case postgres(Configuration, PostgresConnection.Configuration.TLS) + case inMemory + } + + // MARK: Properties + + /// The resolved persistence backend to register. + private let backend: Backend + + /// The logger the database emits through. + private let logger: Logger + + // MARK: Initializers + + /// Creates a factory for a `Fluent` service backed by the given driver. + /// + /// The TLS context for the PostgreSQL backend is built here, once — the factory holds only resolved configuration, so producing the + /// service afterwards cannot fail. + /// - Parameters: + /// - driver: the persistence backend to register. + /// - logger: the logger the database emits through. + /// - Throws: an error when the TLS context for the PostgreSQL backend cannot be built. + public init( + driver: Driver, + logger: Logger + ) throws { + switch driver { + case .postgres(let configuration): + self.backend = .postgres( + configuration, + try configuration.tls.postgresTLS() + ) + case .inMemory: + self.backend = .inMemory + } + + self.logger = logger + } + + // MARK: Methods + + /// 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. + /// - Returns: the configured `Fluent` service, ready to be added to the service group. + public func callAsFunction() -> Fluent { + let fluent = Fluent( + logger: logger + ) + + switch backend { + case .postgres(let configuration, let tls): + fluent.databases.use( + .postgres( + configuration: .init( + hostname: configuration.host, + port: configuration.port, + username: configuration.username, + password: configuration.password, + database: configuration.name, + tls: tls + ), + maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop, + connectionPoolTimeout: .init(configuration.poolTimeout) + ), + as: .psql, + 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. + fluent.databases.use( + .sqlite(.memory, maxConnectionsPerEventLoop: 1), + as: .sqlite, + isDefault: true + ) + } + + return fluent + } + +} diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift new file mode 100644 index 0000000..1a771f8 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift @@ -0,0 +1,65 @@ +/// The connection parameters for the PostgreSQL backend. +/// +/// 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 + + /// The host the database server is reached at. + let host: String + + /// The maximum number of pooled connections opened per event loop. + let maxConnectionsPerEventLoop: Int + + /// The name of the database to open. + let name: String + + /// The password the connection authenticates with. + let password: String + + /// The longest a query waits for a pooled connection to become available before failing. + let poolTimeout: Duration + + /// The port the database server listens on. + let port: Int + + /// The TLS posture used when connecting. + let tls: TLS + + /// The username the connection authenticates as. + let username: String + + // MARK: Initializers + + /// Creates a set of PostgreSQL connection parameters. + /// - Parameters: + /// - host: the host the database server is reached at. + /// - port: the port the database server listens on. + /// - name: the name of the database to open. + /// - username: the username the connection authenticates as. + /// - password: the password the connection authenticates with. + /// - tls: the TLS posture used when connecting. + /// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop. + /// - poolTimeout: the longest a query waits for a pooled connection to become available before failing. + public init( + host: String, + port: Int, + name: String, + username: String, + password: String, + tls: TLS, + maxConnectionsPerEventLoop: Int, + poolTimeout: Duration + ) { + self.host = host + self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop + self.name = name + self.password = password + self.poolTimeout = poolTimeout + self.port = port + self.tls = tls + self.username = username + } + +} + diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift new file mode 100644 index 0000000..37a20c6 --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift @@ -0,0 +1,92 @@ +import Logging +import NIOCore +import NIOPosix +import PostgresNIO +import Testing + +@testable import Persistence + +@Suite( + "TLS enumeration", + .tags(.enumeration) +) +struct TLSTests { + + // MARK: Methods tests + + @Test + func `off connects in plaintext`() async throws { + // With TLS disabled the client skips the `SSLRequest` and sends its startup message directly, + // which the fake server answers in plaintext. + let server = try await PlaintextPostgresServer.start() + let connection = try await connect(to: server, tls: .off) + + let isConnected = !connection.isClosed + + try await connection.close() + try await server.stop() + + #expect(isConnected) + } + + @Test + func `prefer falls back to plaintext when the server offers no TLS`() async throws { + // The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading + // to plaintext — pinning the driver behavior the `prefer` posture relies on. + let server = try await PlaintextPostgresServer.start() + let connection = try await connect(to: server, tls: .prefer) + + let isConnected = !connection.isClosed + + try await connection.close() + try await server.stop() + + #expect(isConnected) + } + + @Test + func `require refuses the connection when the server offers no TLS`() async throws { + // The fake server refuses the `SSLRequest`, so the driver must fail the connection instead of + // downgrading — pinning the refusal the `require` posture promises. + let server = try await PlaintextPostgresServer.start() + + let error = await #expect(throws: PSQLError.self) { + _ = try await connect(to: server, tls: .require) + } + + try await server.stop() + + #expect(error?.code == .sslUnsupported) + } + +} + +// MARK: - Helpers + +private extension TLSTests { + + /// Opens a connection to the given fake server with the given TLS posture. + /// - Parameters: + /// - server: the fake server to connect to. + /// - tls: the TLS posture to connect with. + /// - Returns: the open connection, to be closed by the caller. + func connect( + to server: PlaintextPostgresServer, + tls: TLS + ) async throws -> PostgresConnection { + try await PostgresConnection.connect( + on: MultiThreadedEventLoopGroup.singleton.any(), + configuration: .init( + host: "127.0.0.1", + port: server.port, + username: "loud", + password: "loud", + database: "loud", + tls: tls.postgresTLS() + ), + id: 1, + logger: Logger(label: "test") + ) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift new file mode 100644 index 0000000..c01ab7e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -0,0 +1,121 @@ +import FluentKit +import HummingbirdFluent +import Logging +import Testing + +@testable import Persistence + +@Suite( + "Probe method", + .tags(.method) +) +struct ProbeTests { + + // MARK: Methods tests + + @Test + func `reports a reachable database`() async throws { + let service = try Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + let fluent = service() + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(isReachable) + } + + @Test + func `reports an unreachable database`() async throws { + // Port 1 on the loopback interface has nothing listening, so the connection is refused + // immediately instead of timing out. + let service = try Service( + driver: .postgres( + .init( + host: "127.0.0.1", + port: 1, + name: "unreachable", + username: "nobody", + password: "nothing", + tls: .off, + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) + ) + ), + logger: Logger(label: "test") + ) + let fluent = service() + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(!isReachable) + } + + @Test + func `reports a hanging database as unreachable within its timeout`() async throws { + // The silent server accepts the TCP connection and never answers, so the probe's query can only + // ever be resolved by its deadline — without one, it would wait out the driver's own connect + // timeout (10 seconds) instead. + let server = try await SilentPostgresServer.start() + let service = try Service( + driver: .postgres( + .init( + host: "127.0.0.1", + port: server.port, + name: "hanging", + username: "nobody", + password: "nothing", + tls: .off, + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) + ) + ), + logger: Logger(label: "test") + ) + let fluent = service() + let probe = Probe( + fluent: fluent, + timeout: .milliseconds(100) + ) + + let clock = ContinuousClock() + let start = clock.now + let isReachable = await probe() + let elapsed = clock.now - start + + try await fluent.shutdown() + try await server.stop() + + #expect(!isReachable) + // Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's + // 10-second connect timeout — only the deadline can answer this fast. + #expect(elapsed < .seconds(5)) + } + + @Test + func `reports a default database that is not an SQL database`() async throws { + let fluent = Fluent(logger: Logger(label: "test")) + + fluent.databases.use( + .init(make: { NotSQLConfiguration() }), + as: .init(string: "not-sql"), + isDefault: true + ) + + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(!isReachable) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift new file mode 100644 index 0000000..ad6d38e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Logging +import SQLKit +import Testing + +@testable import Persistence + +@Suite( + "Service method", + .tags(.method) +) +struct ServiceTests { + + // MARK: Methods tests + + @Test + func `registers an SQLite database as the default for the in-memory driver`() async throws { + let service = try Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + + let fluent = service() + let database = fluent.db() as? any SQLDatabase + + try await fluent.shutdown() + + let dialect = try #require(database?.dialect) + + #expect(dialect.name == "sqlite") + } + + @Test + func `registers a PostgreSQL database as the default for the postgres driver`() async throws { + // Resolving the default database opens no connection — pooling is lazy — so no server + // needs to be listening on the configured host and port. + let service = try Service( + driver: .postgres( + .init( + host: "127.0.0.1", + port: 5432, + name: "site", + username: "site", + password: "site", + tls: .off, + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) + ) + ), + logger: Logger(label: "test") + ) + + let fluent = service() + let database = fluent.db() as? any SQLDatabase + + try await fluent.shutdown() + + let dialect = try #require(database?.dialect) + + #expect(dialect.name == "postgresql") + } + + @Test + func `builds a usable in-memory database`() async throws { + let service = try Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + + let fluent = service() + + do { + let database = try #require(fluent.db() as? any SQLDatabase) + + try await database.raw("SELECT 1").run() + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + + @Test("in-memory: migrate, insert, read back") + func inMemoryRoundTrip() async throws { + try await roundTrip(driver: .inMemory) + } + + @Test( + "postgres: migrate, insert, read back", + .enabled(if: postgresDriver != nil) + ) + func postgresRoundTrip() async throws { + try await roundTrip( + driver: postgresDriver!, + revertAfter: true + ) + } + +} + +// MARK: - Helpers + +private extension ServiceTests { + + /// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after. + /// + /// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that, + /// the test must shut it down explicitly — even on failure — or the pool asserts on `deinit`. + /// - Parameters: + /// - driver: the persistence backend to exercise. + /// - revertAfter: whether to revert the migrations afterwards; set for a shared database (the + /// in-memory database is discarded on shutdown, so it needs no revert). + func roundTrip( + driver: Persistence.Driver, + revertAfter: Bool = false + ) async throws { + let prepareDB = PrepareDB() + let service = try Service( + driver: driver, + logger: Logger(label: "test") + ) + + let fluent = service() + + do { + await prepareDB(for: fluent) + + try await fluent.migrate() + + let repository = ExampleRepository(fluent: fluent) + let created = try await repository.create(name: "site") + + #expect(try await repository.all().contains(created)) + + if revertAfter { + try await fluent.revert() + } + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + +} + +/// The PostgreSQL driver built from the `POSTGRES_TEST_*` environment variables, or `nil` when the gate +/// variable `POSTGRES_TEST_HOST` is unset — in which case the PostgreSQL integration test is skipped, so +/// the suite stays runnable with no database available. +private let postgresDriver: Persistence.Driver? = { + let environment = ProcessInfo.processInfo.environment + + guard let host = environment["POSTGRES_TEST_HOST"] else { + return nil + } + + return .postgres( + .init( + host: host, + port: environment["POSTGRES_TEST_PORT"].flatMap(Int.init) ?? 5432, + name: environment["POSTGRES_TEST_NAME"] ?? "site", + username: environment["POSTGRES_TEST_USERNAME"] ?? "site", + password: environment["POSTGRES_TEST_PASSWORD"] ?? "site", + tls: .off, + maxConnectionsPerEventLoop: 2, + poolTimeout: .seconds(10) + ) + ) +}() 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/NotSQLConfiguration.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift new file mode 100644 index 0000000..ed276e5 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift @@ -0,0 +1,13 @@ +import FluentKit + +struct NotSQLConfiguration: DatabaseConfiguration { + + var middleware: [any AnyModelMiddleware] = [] + + func makeDriver( + for databases: Databases + ) -> any DatabaseDriver { + NotSQLDriver() + } + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift new file mode 100644 index 0000000..a612808 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift @@ -0,0 +1,44 @@ +import FluentKit + +/// A Fluent database that is not an `SQLDatabase`, so the probe's downcast fails. +/// +/// Every query succeeds, proving the probe reports "not reachable" because of the failed downcast +/// rather than a failing backend. +struct NotSQLDatabase: Database { + + let context: DatabaseContext + + var inTransaction: Bool { false } + + func execute( + query: DatabaseQuery, + onOutput: @escaping @Sendable (any DatabaseOutput) -> Void + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func execute( + schema: DatabaseSchema + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func execute( + enum: DatabaseEnum + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func transaction( + _ closure: @escaping @Sendable (any Database) -> EventLoopFuture + ) -> EventLoopFuture { + closure(self) + } + + func withConnection( + _ closure: @escaping @Sendable (any Database) -> EventLoopFuture + ) -> EventLoopFuture { + closure(self) + } + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift new file mode 100644 index 0000000..82b8ff4 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift @@ -0,0 +1,13 @@ +import FluentKit + +struct NotSQLDriver: DatabaseDriver { + + func makeDatabase( + with context: DatabaseContext + ) -> any Database { + NotSQLDatabase(context: context) + } + + func shutdown() {} + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift new file mode 100644 index 0000000..0df114d --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift @@ -0,0 +1,169 @@ +import NIOCore +import NIOPosix + +/// A fake PostgreSQL server speaking just enough of the wire protocol to complete a plaintext startup. +/// +/// It answers the client's `SSLRequest` with `'N'` (no SSL) and the subsequent startup message with +/// `AuthenticationOk`, `BackendKeyData`, and `ReadyForQuery` — 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, and what the `require` test connects to, +/// proving the driver refuses the connection instead of downgrading. +final class PlaintextPostgresServer { + + // 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 -> PlaintextPostgresServer { + 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 PlaintextPostgresServer { + + /// Refuses the client's `SSLRequest`, accepts whatever startup message arrives, and closes on anything + /// after that (e.g. a `Terminate`). + /// + /// Unlike MySQL, the PostgreSQL client speaks first, so nothing is written on `channelActive`. + final class Handler: ChannelInboundHandler { + + // MARK: Type aliases + + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + // MARK: Enumerations + + /// The startup phases the connection moves through. + private enum State { + case awaitingSSLRequest + case awaitingStartup + case established + } + + // MARK: Constants + + /// The magic code identifying an `SSLRequest` message. + private static let sslRequestCode: Int32 = 80877103 + + // MARK: Properties + + /// The startup phase the connection is currently in. + private var state: State = .awaitingSSLRequest + + // MARK: Functions + + func channelRead( + context: ChannelHandlerContext, + data: NIOAny + ) { + let buffer = unwrapInboundIn(data) + + switch state { + case .awaitingSSLRequest: + // Peek past the Int32 length at the Int32 code: an `SSLRequest` is refused with a bare + // 'N', while a direct startup message (a client connecting with TLS disabled) is + // answered straight away. + guard buffer.getInteger(at: buffer.readerIndex + 4, as: Int32.self) == Self.sslRequestCode else { + completeStartup(context: context) + return + } + + state = .awaitingStartup + + var refusal = context.channel.allocator.buffer(capacity: 1) + + refusal.writeInteger(UInt8(ascii: "N")) + + context.writeAndFlush( + wrapOutboundOut(refusal), + promise: nil + ) + case .awaitingStartup: + completeStartup(context: context) + case .established: + context.close(promise: nil) + } + } + + /// Answers a startup message and marks the connection established. + private func completeStartup(context: ChannelHandlerContext) { + state = .established + + context.writeAndFlush( + wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)), + promise: nil + ) + } + + // MARK: Helpers + + /// The reply completing a plaintext startup: `AuthenticationOk`, `BackendKeyData`, and + /// `ReadyForQuery` in a single flush. + /// + /// `BackendKeyData` is not optional filler — the client requires it before `ReadyForQuery` by + /// default and fails the connection when it is missing. + private static func startupResponse(allocator: ByteBufferAllocator) -> ByteBuffer { + var buffer = allocator.buffer(capacity: 32) + + buffer.writeInteger(UInt8(ascii: "R")) // AuthenticationOk + buffer.writeInteger(Int32(8)) + buffer.writeInteger(Int32(0)) + + buffer.writeInteger(UInt8(ascii: "K")) // BackendKeyData + buffer.writeInteger(Int32(12)) + buffer.writeInteger(Int32(1)) // process id + buffer.writeInteger(Int32(0)) // secret key + + buffer.writeInteger(UInt8(ascii: "Z")) // ReadyForQuery + buffer.writeInteger(Int32(5)) + buffer.writeInteger(UInt8(ascii: "I")) // idle + + return buffer + } + + } + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift b/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift new file mode 100644 index 0000000..d1498c1 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift @@ -0,0 +1,54 @@ +import NIOCore +import NIOPosix + +/// A fake server accepting connections and never answering. +/// +/// A client connecting to it completes the TCP handshake and then waits forever for the first protocol byte — the shape of a database that hangs rather +/// than refuses. This is what the probe's deadline test connects to, proving the probe answers within its timeout instead of hanging alongside the server. +final class SilentPostgresServer { + + // 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 -> SilentPostgresServer { + let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .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() + } + +} 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..5f62ff0 --- /dev/null +++ b/Packages/Utility/README.md @@ -0,0 +1,30 @@ +# 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 +| 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 for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits. + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling 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/.dockerignore b/Services/Website/.dockerignore new file mode 100644 index 0000000..1f3233b --- /dev/null +++ b/Services/Website/.dockerignore @@ -0,0 +1,7 @@ +.build +.swiftpm +.DS_Store +.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..5fccdf3 --- /dev/null +++ b/Services/Website/.env.local @@ -0,0 +1,67 @@ +# Local `.env` file used solely for Development purposes. + +# --- 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=ccn + +# 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 deployment image +IMAGE_PLATFORM=linux/amd64 + +# Platform of the *local* build only (docker-compose.override.yml) +BUILD_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=CCNWebsite + +# Log verbosity: trace | debug | info | notice | warning | error | critical +LOG_LEVEL=debug + +# --- Persistence ---------------------------------------------------------------- + +# Persistence driver: inMemory (default, no infrastructure) or postgres. +DATABASE_DRIVER=inMemory + +# PostgreSQL connection, used when DATABASE_DRIVER=postgres. +DATABASE_HOST=localhost + +# Name of the database to connect to. +DATABASE_NAME=ccn + +# Provide the real password via the environment or a secret — never commit it. +DATABASE_PASSWORD=ccn + +# Maximum pooled connections per event loop, one loop per core — an 8-core host +# can open 8 times this, and every replica that many again. +DATABASE_POOL_MAX_PER_EVENT_LOOP=4 + +# Port of the database to connect to. +DATABASE_PORT=5432 + +# TLS posture when connecting: off | prefer | require. Plaintext is the usual +# choice over a private container network; use `require` across one you share. +DATABASE_TLS=off + +# Username of the database to connect as. +DATABASE_USERNAME=ccn \ No newline at end of file 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..b41701b --- /dev/null +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile new file mode 100644 index 0000000..df4967d --- /dev/null +++ b/Services/Website/Dockerfile @@ -0,0 +1,135 @@ +# ================================ +# Asset image +# ================================ +FROM node:22-alpine AS assets + +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 StaticFile enumeration stay unchanged. +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 --recursive . \ + && svgo --recursive --folder . + +# 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 / + +# ================================ +# 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 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 ./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 + +# 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/Utility/Sources ./Packages/Utility/Sources +COPY ./Services/Website/Sources ./Services/Website/Sources + +# Build the application, with optimizations, with static linking, and using jemalloc. +# +# SPM validates the path of every target in the root package — including the test targets — even when only the +# executable product is built, so those directories have to exist. They are created empty instead of copied: the test +# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer, +# and it keeps the local database bind mount under Tests/DB out of the image entirely. +RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \ + && swift build --package-path ./Services/Website -c release \ + --product "Website" \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area, without its debug sections +RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./ \ + && strip --strip-debug ./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/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 +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. +RUN chmod -R a-w ./Resources + +# ================================ +# 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 \ + curl \ + 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..a62f677 --- /dev/null +++ b/Services/Website/Makefile @@ -0,0 +1,168 @@ +# 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: ## Reset the SPM cache and build folder + @swift package reset + +.PHONY: pkg-outdated +pkg-outdated: ## List the SPM dependencies that can be updated + @swift package update --dry-run + +.PHONY: pkg-update +pkg-update: ## Update the SPM dependencies + @swift package update + +# --- Local development -------------------------------------------------------- + +.PHONY: site-run +site-run: ## Run the website locally, rebuilding on source changes + @hb watch + +.PHONY: site-mount +site-mount: ## Mount the website locally + @docker compose up \ + --build \ + --detach + +.PHONY: site-unmount +site-unmount: ## Unmount and remove the local website + @docker compose down + @$(MAKE) img-remove + +# --- Local database ----------------------------------------------------------- + +.PHONY: db-mount +db-mount: ## Start the local database instance + @docker compose \ + --profile database up \ + --detach \ + --wait postgres + +.PHONY: db-migrate +db-migrate: ## Run the migrations against the local database instance + @DATABASE_DRIVER=postgres \ + DATABASE_HOST=127.0.0.1 \ + DATABASE_TLS=off \ + swift run \ + Website \ + --database-migrate + +.PHONY: db-shell +db-shell: ## Open a SQL shell on the local database instance + @docker compose \ + --profile database \ + exec \ + --env PGPASSWORD=$(or $(DATABASE_PASSWORD),ccn) \ + postgres \ + psql \ + --username=$(or $(DATABASE_USERNAME),ccn) \ + --dbname=$(or $(DATABASE_NAME),ccn) + +.PHONY: db-unmount +db-unmount: ## Stop and remove the local database instance while keeping its data + @docker compose \ + --profile database down postgres + +.PHONY: db-reset +db-reset: ## Stop and remove the local database instance, then delete its data + @docker compose \ + --profile database down postgres \ + --volumes + @if [ -d Tests/DB ]; then \ + rm -rf Tests/DB; \ + mkdir -p Tests/DB; \ + fi + +# --- Assets minification ------------------------------------------------------ + +.PHONY: ast-minify +ast-minify: ## Preview the minified JS/CSS assets in .build/minified + @docker build \ + --target assets-export \ + --output .build/minified \ + --file Dockerfile \ + ../.. + @echo "Minified assets written to .build/minified" + +# --- Registry deployment ------------------------------------------------------ + +.PHONY: img-check +img-check: ## Check the production image builds + @docker build \ + --platform $(IMAGE_PLATFORM) \ + --file Dockerfile \ + ../.. + +.PHONY: img-release +img-release: ## Build and push the production image into the container registry + @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 \ + --file Dockerfile \ + ../.. + @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..f21be13 --- /dev/null +++ b/Services/Website/Package.swift @@ -0,0 +1,152 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Website", + defaultLocalization: "en", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + ], + products: [ + .executable( + name: "Website", + targets: [ + "Website", + "WebsiteLibrary", + ] + ) + ], + dependencies: [ + .package( + path: "../../Packages/Infrastructure" + ), + .package( + path: "../../Packages/Localization" + ), + .package( + path: "../../Packages/Persistence" + ), + .package( + path: "../../Packages/Utility" + ), + .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" + ), + .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", + traits: [ + .defaults, + "CommandLineArguments", + ] + ), + ], + targets: [ + .executableTarget( + name: "Website", + dependencies: [ + .byName(name: "Localization"), + .byName(name: "Persistence"), + .byName(name: "WebsiteLibrary"), + .product( + name: "Configuration", + package: "swift-configuration" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + .product( + name: "HummingbirdCompression", + package: "hummingbird-compression" + ), + ], + path: "Sources/App" + ), + .target( + name: "WebsiteLibrary", + dependencies: [ + .byName(name: "Infrastructure"), + .byName(name: "Localization"), + .byName(name: "Persistence"), + .byName(name: "Utility"), + .product( + name: "Configuration", + package: "swift-configuration" + ), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + .product( + name: "HummingbirdElementary", + package: "hummingbird-elementary" + ), + ], + path: "Sources/Library", + 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") + ] + ), + .testTarget( + name: "WebsiteTests", + dependencies: [ + .byName(name: "Infrastructure"), + .byName(name: "Website"), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + ], + path: "Tests/App", + resources: [ + // `Static` links to the service's `Resources/Static`, copying it into the test bundle at build + // time — the tests must not read the repository tree, which Xcode's test runner is denied. + .copy("Static") + ] + ), + .testTarget( + name: "WebsiteLibraryTests", + dependencies: [ + .byName(name: "Infrastructure"), + .byName(name: "Persistence"), + .byName(name: "WebsiteLibrary"), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + ], + path: "Tests/Library" + ), + ] +) diff --git a/Services/Website/README.md b/Services/Website/README.md new file mode 100644 index 0000000..6661a44 --- /dev/null +++ b/Services/Website/README.md @@ -0,0 +1,349 @@ +# CCN Website +The **CCN** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets. + +## Overview +The service: +- Serves the landing page at `GET /` (rendered once 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 `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. +- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)). +- 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). +- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`. +- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) 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 not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file. +- Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)). +- 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 PostgreSQL server, selected by a single configuration key. + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- Docker (optional) for the containerized run/deploy workflow. +- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change). + +## Architecture +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, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. | + +The `Website` executable depends on four local packages, each under `Packages/`: +- `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). +- `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 `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable. +- `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` — 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). + +Requests pass through the middleware chain in this order (outermost first), then reach the routes: +``` +LogRequestsMiddleware + → 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 not-found page on .notFound) + → FileMiddleware (serves Resources/Static) +RootController (GET / → landing page) +HealthController (GET /health → liveness, GET /health/ready → readiness) +``` + +The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free. + +### Page metadata +Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags. + +Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values): +| Property | Renders as | Notes | +| --- | --- | --- | +| `summary` | `` | 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` | `