Merge branch 'setup' into template
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
|
||||
|
||||
## Overview
|
||||
The package provides, grouped by role:
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||
@@ -10,14 +9,16 @@ The package provides, grouped by role:
|
||||
| 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 (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them.
|
||||
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need.
|
||||
- **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
|
||||
@@ -32,7 +33,7 @@ Sources/
|
||||
│ ├── Middlewares/ the five HTTP middlewares
|
||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||
│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/
|
||||
│ └── 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)
|
||||
@@ -43,8 +44,8 @@ Tests/
|
||||
```
|
||||
|
||||
## Testing
|
||||
Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||
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 `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
|
||||
@@ -4,8 +4,8 @@ import Foundation
|
||||
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
|
||||
///
|
||||
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
|
||||
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
|
||||
/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
|
||||
/// 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
|
||||
@@ -18,13 +18,16 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
|
||||
// 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 before the ``scripts``.
|
||||
/// The page's markup, rendered as the document body.
|
||||
@HTMLBuilder
|
||||
var content: Content { get }
|
||||
|
||||
@@ -35,7 +38,10 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
@HTMLBuilder
|
||||
var metadata: Metadata { get }
|
||||
|
||||
/// The scripts loaded at the end of the document body, in order.
|
||||
/// The scripts loaded from the document head, in order.
|
||||
///
|
||||
/// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the
|
||||
/// document is fully parsed — the same semantics end-of-body tags would give, minus the late download start.
|
||||
var scripts: [any Asset] { get }
|
||||
|
||||
/// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
|
||||
@@ -64,34 +70,38 @@ public extension Page {
|
||||
nil
|
||||
}
|
||||
|
||||
/// The page ``content`` followed by its ``scripts``.
|
||||
/// The page ``content``; the ``scripts`` load deferred from the ``head``.
|
||||
@HTMLBuilder
|
||||
var body: some HTML {
|
||||
content
|
||||
|
||||
for file in scripts {
|
||||
script(.src(file.urlPath(
|
||||
for: .js,
|
||||
version: assetVersion
|
||||
))) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
|
||||
/// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
|
||||
/// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard``
|
||||
/// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the
|
||||
/// ``stylesheets`` links and the deferred ``scripts`` tags, placed in the document head.
|
||||
///
|
||||
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` 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.
|
||||
/// 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),
|
||||
@@ -127,6 +137,30 @@ public extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -138,8 +172,23 @@ public extension Page {
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
for file in scripts {
|
||||
script(
|
||||
.defer,
|
||||
.src(file.urlPath(
|
||||
for: .js,
|
||||
version: assetVersion
|
||||
))
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The analytics tracker is omitted unless the page provides one.
|
||||
var analytics: Analytics? {
|
||||
nil
|
||||
}
|
||||
|
||||
/// The social card is omitted unless the page provides one.
|
||||
var socialCard: SocialCard? {
|
||||
nil
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
/// The analytics tracker a page embeds: where the script loads from, which site it reports as, which domains it reports from, and how it behaves.
|
||||
///
|
||||
/// A page carries it as an optional value so the ``Page`` scaffolding renders the tracker's deferred `<script>` in the document head, or omits it
|
||||
/// when the page has none. The attribute names follow the Umami tracker convention. The three behavior flags default to on, and each renders its
|
||||
/// `data-` attribute only when enabled, since the tracker treats an absent attribute as off. Recorder mode, by contrast, defaults to off; when
|
||||
/// activated, the page embeds a second deferred script that loads the session recorder from the tracker's origin.
|
||||
public struct Analytics: Sendable {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
public typealias Attribute = (name: String, value: String)
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether the tracker drops the URL fragment from reported pageviews, so fragment navigation does not split a page's views.
|
||||
public let excludeHash: Bool
|
||||
|
||||
/// Whether the tracker honors the visitor's browser Do Not Track preference.
|
||||
public let doNotTrack: Bool
|
||||
|
||||
/// The comma-delimited domains the tracker reports from; visits from any other host are ignored.
|
||||
public let domains: String
|
||||
|
||||
/// Whether the tracker collects Core Web Vitals from visitors (requires an Umami instance at v3.1 or newer).
|
||||
public let performance: Bool
|
||||
|
||||
/// Whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker.
|
||||
public let recorder: Bool
|
||||
|
||||
/// The URL the tracker script is loaded from.
|
||||
public let scriptURL: String
|
||||
|
||||
/// The analytics website identifier the tracker reports as.
|
||||
public let websiteID: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an analytics configuration.
|
||||
/// - Parameters:
|
||||
/// - scriptURL: the URL the tracker script is loaded from.
|
||||
/// - websiteID: the analytics website identifier the tracker reports as.
|
||||
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored.
|
||||
/// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`.
|
||||
/// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`.
|
||||
/// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`.
|
||||
/// - recorder: whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker; defaults to `false`.
|
||||
public init(
|
||||
scriptURL: String,
|
||||
websiteID: String,
|
||||
domains: String,
|
||||
excludeHash: Bool = true,
|
||||
doNotTrack: Bool = true,
|
||||
performance: Bool = true,
|
||||
recorder: Bool = false
|
||||
) {
|
||||
self.scriptURL = scriptURL
|
||||
self.websiteID = websiteID
|
||||
self.domains = domains
|
||||
self.excludeHash = excludeHash
|
||||
self.doNotTrack = doNotTrack
|
||||
self.performance = performance
|
||||
self.recorder = recorder
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The tracker script's attributes, in a stable order: the website id and the reporting domains, then each enabled behavior flag.
|
||||
///
|
||||
/// A disabled flag is left out entirely, since the tracker treats an absent attribute as off. Each `name` is a full attribute name following the
|
||||
/// Umami `data-` convention, which a page applies to the deferred script verbatim — so the page renders the tracker without knowing its shape.
|
||||
public var attributes: [Attribute] {
|
||||
var attributes = [(
|
||||
name: "data-website-id",
|
||||
value: websiteID
|
||||
), (
|
||||
name: "data-domains",
|
||||
value: domains
|
||||
)]
|
||||
|
||||
if excludeHash {
|
||||
attributes.append((
|
||||
name: "data-exclude-hash",
|
||||
value: "true"
|
||||
))
|
||||
}
|
||||
|
||||
if doNotTrack {
|
||||
attributes.append((
|
||||
name: "data-do-not-track",
|
||||
value: "true"
|
||||
))
|
||||
}
|
||||
|
||||
if performance {
|
||||
attributes.append((
|
||||
name: "data-performance",
|
||||
value: "true"
|
||||
))
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
/// The origin the tracker is served from (scheme and host, plus any explicit port), derived from the ``scriptURL``, or `nil` when the
|
||||
/// URL carries no scheme or host.
|
||||
///
|
||||
/// A page renders it as a `preconnect` hint before the tracker script, so the connection handshake starts as early as possible.
|
||||
public var origin: String? {
|
||||
guard
|
||||
let url = URL(string: scriptURL),
|
||||
let scheme = url.scheme,
|
||||
let host = url.host
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let port = url.port.map { ":\($0)" } ?? ""
|
||||
|
||||
return "\(scheme)://\(host)\(port)"
|
||||
}
|
||||
|
||||
/// The URL the session recorder script is loaded from, derived from the tracker's ``origin``, or `nil` when ``recorder`` mode is off
|
||||
/// or no origin can be derived from the ``scriptURL``.
|
||||
///
|
||||
/// A page renders it as a second deferred script tag after the tracker script, carrying only the `data-website-id` attribute.
|
||||
public var recorderScriptURL: String? {
|
||||
guard recorder, let origin else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "\(origin)/recorder.js"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@ struct PageTests {
|
||||
#expect(html.contains(#"name="viewport""#))
|
||||
#expect(html.contains(#"<meta name="stub" content="marker">"#))
|
||||
#expect(html.contains(#"<link rel="stylesheet" href="/css/stub.css">"#))
|
||||
#expect(html.contains(#"<script src="/js/stub.js"></script>"#))
|
||||
#expect(html.contains(#"<script defer src="/js/stub.js"></script>"#))
|
||||
#expect(html.contains("Stub content"))
|
||||
}
|
||||
|
||||
@@ -38,13 +38,17 @@ struct PageTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the scripts after the content`() throws {
|
||||
func `renders the scripts deferred in the head, after the stylesheets`() throws {
|
||||
let html = StubPage().render()
|
||||
|
||||
let content = try #require(html.range(of: "Stub content"))
|
||||
let stylesheet = try #require(html.range(of: "/css/stub.css"))
|
||||
let script = try #require(html.range(of: "/js/stub.js"))
|
||||
let content = try #require(html.range(of: "Stub content"))
|
||||
|
||||
#expect(content.lowerBound < script.lowerBound)
|
||||
// Deferred head scripts start downloading during head parsing but still execute, in order, only after the
|
||||
// document is parsed — the semantics end-of-body tags gave, minus the late download start.
|
||||
#expect(stylesheet.lowerBound < script.lowerBound)
|
||||
#expect(script.lowerBound < content.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,6 +126,75 @@ struct PageTests {
|
||||
))
|
||||
}
|
||||
|
||||
@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(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true" data-do-not-track="true" data-performance="true"></script>"#))
|
||||
|
||||
// The preconnect hint warms the tracker origin's connection before the parser reaches the script tag.
|
||||
let preconnect = try #require(html.range(of: #"<link rel="preconnect" href="https://analytics.example.com">"#))
|
||||
let script = try #require(html.range(of: #"<script defer src="https://analytics.example.com/script""#))
|
||||
|
||||
#expect(preconnect.lowerBound < script.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the session recorder script by default`() {
|
||||
let html = StubPage(analytics: .init(
|
||||
scriptURL: "https://analytics.example.com/script",
|
||||
websiteID: "0000-website-id",
|
||||
domains: "example.com"
|
||||
)).render()
|
||||
|
||||
#expect(!html.contains("recorder.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the session recorder script when recorder mode is on`() throws {
|
||||
let html = StubPage(analytics: .init(
|
||||
scriptURL: "https://analytics.example.com/script",
|
||||
websiteID: "0000-website-id",
|
||||
domains: "example.com",
|
||||
recorder: true
|
||||
)).render()
|
||||
|
||||
#expect(html.contains(#"<script defer src="https://analytics.example.com/recorder.js" data-website-id="0000-website-id"></script>"#))
|
||||
|
||||
let tracker = try #require(html.range(of: #"<script defer src="https://analytics.example.com/script""#))
|
||||
let recorder = try #require(html.range(of: #"<script defer src="https://analytics.example.com/recorder.js""#))
|
||||
|
||||
#expect(tracker.lowerBound < recorder.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the analytics behavior flags that are disabled`() {
|
||||
let html = StubPage(analytics: .init(
|
||||
scriptURL: "https://analytics.example.com/script",
|
||||
websiteID: "0000-website-id",
|
||||
domains: "example.com",
|
||||
excludeHash: true,
|
||||
doNotTrack: false,
|
||||
performance: false
|
||||
)).render()
|
||||
|
||||
#expect(html.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true"></script>"#))
|
||||
#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()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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",
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,9 @@ struct StubPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
|
||||
@@ -34,6 +37,8 @@ struct StubPage: Page {
|
||||
/// default) to leave them unversioned.
|
||||
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
|
||||
/// to omit it.
|
||||
/// - analytics: the analytics tracker rendered as a deferred script in the document head,
|
||||
/// or `nil` (the default) to omit it.
|
||||
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
||||
/// (the default) to omit them.
|
||||
/// - structuredData: the structured data rendered as a JSON-LD script in the document
|
||||
@@ -44,10 +49,12 @@ struct StubPage: Page {
|
||||
locale: Locale = .init(identifier: "en"),
|
||||
assetVersion: String? = nil,
|
||||
canonicalURL: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
socialCard: SocialCard? = nil,
|
||||
structuredData: StructuredData? = nil,
|
||||
summary: String? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.canonicalURL = canonicalURL
|
||||
self.locale = locale
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||
|
||||
## Overview
|
||||
The package provides, grouped by role:
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
|
||||
@@ -11,10 +10,10 @@ The package provides, grouped by role:
|
||||
| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
|
||||
|
||||
## Design rules
|
||||
- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`. Adding a language is a translation-only change — once a locale exists in the catalog, `LanguageList` and `Negotiate` pick it up with no code change.
|
||||
- **The 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 catalog resource verbatim (not `.process` it) so it ships as raw JSON on every platform. Only simple `stringUnit` values are decoded; plural and device variations are not represented.
|
||||
- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` once at startup and warn when it is not `.loaded`, before visitors ever see raw keys.
|
||||
- **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`.
|
||||
|
||||
@@ -36,9 +35,9 @@ Tests/
|
||||
```
|
||||
|
||||
## Testing
|
||||
Every suite carries a tag naming the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||
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 `Infrastructure` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- 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.
|
||||
|
||||
@@ -21,8 +21,8 @@ let package = Package(
|
||||
from: "2.0.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/vapor/fluent-mysql-driver.git",
|
||||
from: "4.8.0"
|
||||
url: "https://github.com/vapor/fluent-postgres-driver.git",
|
||||
from: "2.12.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/vapor/fluent-sqlite-driver.git",
|
||||
@@ -33,12 +33,16 @@ let package = Package(
|
||||
from: "3.36.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/vapor/mysql-nio.git",
|
||||
from: "1.7.0"
|
||||
url: "https://github.com/vapor/postgres-nio.git",
|
||||
from: "1.33.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/apple/swift-nio.git",
|
||||
from: "2.65.0"
|
||||
from: "2.81.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/apple/swift-nio-ssl.git",
|
||||
from: "2.25.0"
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
@@ -50,8 +54,8 @@ let package = Package(
|
||||
package: "hummingbird-fluent"
|
||||
),
|
||||
.product(
|
||||
name: "FluentMySQLDriver",
|
||||
package: "fluent-mysql-driver"
|
||||
name: "FluentPostgresDriver",
|
||||
package: "fluent-postgres-driver"
|
||||
),
|
||||
.product(
|
||||
name: "FluentSQLiteDriver",
|
||||
@@ -61,6 +65,14 @@ let package = Package(
|
||||
name: "SQLKit",
|
||||
package: "sql-kit"
|
||||
),
|
||||
.product(
|
||||
name: "PostgresNIO",
|
||||
package: "postgres-nio"
|
||||
),
|
||||
.product(
|
||||
name: "NIOSSL",
|
||||
package: "swift-nio-ssl"
|
||||
),
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
@@ -69,8 +81,8 @@ let package = Package(
|
||||
dependencies: [
|
||||
.byName(name: "Persistence"),
|
||||
.product(
|
||||
name: "MySQLNIO",
|
||||
package: "mysql-nio"
|
||||
name: "PostgresNIO",
|
||||
package: "postgres-nio"
|
||||
),
|
||||
.product(
|
||||
name: "NIOCore",
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
# Persistence
|
||||
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a MySQL/MariaDB backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||
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
|
||||
The package provides, grouped by role:
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Backend selection | `Driver` (`mysql` or `inMemory`), `Configuration` (the MySQL/MariaDB connection parameters), `TLS` (the connection's TLS posture) |
|
||||
| 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` |
|
||||
| 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. See the Website service's `ConfigReader+Properties` for the mapping.
|
||||
- **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` is the single place migrations are registered, in the order they must run; alter the schema by adding a new migration, never by editing one that has already run. Registering does not apply them — the in-memory backend is migrated on startup, while a shared MySQL/MariaDB database is migrated out of band (the executable's migrate-and-exit mode), so multiple booting instances never race.
|
||||
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types; repositories map them to `Sendable` value-type snapshots (e.g. `Example`) before returning, and the models themselves stay internal to the package.
|
||||
- **Readiness never throws.** `Probe` runs a `SELECT 1` — the cheapest statement both backends understand, independent of any schema — and maps every failure to `false`, so callers translate it straight into a readiness response.
|
||||
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop so every query reaches the same in-memory database, rather than each pooled connection getting its own private one.
|
||||
- **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 `prefer` TLS posture is enforced by the driver itself: a supplied TLS configuration upgrades the connection only when the server advertises TLS, and continues in plaintext otherwise (pinned by a test against a fake server that offers no TLS). `require` currently maps to the same configuration and therefore behaves like `prefer` — the refusal when the server offers no TLS is not yet enforced.
|
||||
> **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:
|
||||
@@ -37,24 +36,21 @@ Sources/
|
||||
Tests/
|
||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||
└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
|
||||
plaintext-only fake MySQL server, and the suite Tag constants
|
||||
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 MySQL/MariaDB integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`, `MYSQL_TEST_NAME`, `MYSQL_TEST_USERNAME`, and `MYSQL_TEST_PASSWORD`); it reverts its migrations afterwards, so the shared database is left as it was found:
|
||||
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
|
||||
# in-memory only
|
||||
swift test
|
||||
# or
|
||||
# with the local MariaDB up (make db-mount):
|
||||
MYSQL_TEST_HOST=127.0.0.1 swift test
|
||||
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` pattern around `fluent.shutdown()` is the shape to follow.
|
||||
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 naming the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||
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 `Infrastructure` and `Localization` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- Package dependencies: `hummingbird-fluent`, `fluent-mysql-driver`, `fluent-sqlite-driver`, and `sql-kit`; the test target additionally depends on `mysql-nio` and `swift-nio` for the TLS fallback test.
|
||||
- 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.
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
/// that default and stay agnostic of which backend is in use.
|
||||
public enum Driver: Sendable {
|
||||
|
||||
/// A MySQL/MariaDB server, reached with the given connection parameters.
|
||||
/// 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 mysql(Configuration)
|
||||
case postgres(Configuration)
|
||||
|
||||
/// An ephemeral, in-process SQLite database held entirely in memory.
|
||||
///
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 MySQL driver
|
||||
/// receives the resulting `TLSConfiguration` through ``tlsConfiguration``.
|
||||
/// The executable derives a posture from its `database.tls` configuration and passes it along as part of ``Configuration``; the PostgreSQL driver
|
||||
/// receives the resulting connection TLS mode through ``postgresTLS()``.
|
||||
public enum TLS: Sendable {
|
||||
|
||||
/// Connect without TLS, in plaintext.
|
||||
@@ -13,28 +14,27 @@ public enum TLS: Sendable {
|
||||
case prefer
|
||||
|
||||
/// Connect only over TLS, refusing the connection when the server offers none.
|
||||
///
|
||||
/// - Important: the refusal is not yet enforced — until it is, `require` behaves like ``prefer`` and silently falls back to plaintext when the
|
||||
/// server offers no TLS.
|
||||
case require
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: - Methods
|
||||
|
||||
extension TLS {
|
||||
|
||||
/// The NIO TLS configuration passed to the MySQL driver for this posture.
|
||||
/// The connection TLS mode passed to the PostgreSQL driver for this posture.
|
||||
///
|
||||
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``.
|
||||
/// 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.
|
||||
///
|
||||
/// - Note: the driver gives a supplied configuration ``prefer`` semantics natively — it upgrades to TLS only when the server advertises support,
|
||||
/// and continues in plaintext otherwise — so `prefer` is fully enforced. `require` maps to the same configuration and therefore currently
|
||||
/// behaves like ``prefer``: the refusal when the server offers no TLS is not yet enforced.
|
||||
var tlsConfiguration: TLSConfiguration? {
|
||||
/// - 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: nil
|
||||
default: .makeClientConfiguration()
|
||||
case .off: .disable
|
||||
case .prefer: .prefer(try NIOSSLContext(configuration: .makeClientConfiguration()))
|
||||
case .require: .require(try NIOSSLContext(configuration: .makeClientConfiguration()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,37 +12,66 @@ public struct Probe: Sendable {
|
||||
/// 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.
|
||||
/// - Parameter fluent: the `Fluent` service whose default database is probed.
|
||||
/// - 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
|
||||
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 MySQL/MariaDB and SQLite backends understand — so
|
||||
/// 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, so callers can map it straight onto a readiness response. A default database that is not an SQL
|
||||
/// database is likewise reported as not reachable.
|
||||
/// - Returns: `true` when the database answers the probe, `false` otherwise.
|
||||
/// 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
|
||||
}
|
||||
|
||||
do {
|
||||
try await database.raw("SELECT 1").run()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import FluentMySQLDriver
|
||||
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 persistence backend to register.
|
||||
private let driver: Driver
|
||||
/// The resolved persistence backend to register.
|
||||
private let backend: Backend
|
||||
|
||||
/// The logger the database emits through.
|
||||
private let logger: Logger
|
||||
@@ -19,14 +28,27 @@ public struct Service: Sendable {
|
||||
// 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
|
||||
) {
|
||||
self.driver = driver
|
||||
) throws {
|
||||
switch driver {
|
||||
case .postgres(let configuration):
|
||||
self.backend = .postgres(
|
||||
configuration,
|
||||
try configuration.tls.postgresTLS()
|
||||
)
|
||||
case .inMemory:
|
||||
self.backend = .inMemory
|
||||
}
|
||||
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
@@ -43,21 +65,22 @@ public struct Service: Sendable {
|
||||
logger: logger
|
||||
)
|
||||
|
||||
switch driver {
|
||||
case .mysql(let configuration):
|
||||
switch backend {
|
||||
case .postgres(let configuration, let tls):
|
||||
fluent.databases.use(
|
||||
.mysql(
|
||||
.postgres(
|
||||
configuration: .init(
|
||||
hostname: configuration.host,
|
||||
port: configuration.port,
|
||||
username: configuration.username,
|
||||
password: configuration.password,
|
||||
database: configuration.name,
|
||||
tlsConfiguration: configuration.tls.tlsConfiguration
|
||||
tls: tls
|
||||
),
|
||||
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop
|
||||
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop,
|
||||
connectionPoolTimeout: .init(configuration.poolTimeout)
|
||||
),
|
||||
as: .mysql,
|
||||
as: .psql,
|
||||
isDefault: true
|
||||
)
|
||||
case .inMemory:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// The connection parameters for the MySQL/MariaDB backend.
|
||||
/// 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 {
|
||||
@@ -17,6 +17,9 @@ public struct Configuration: Sendable {
|
||||
/// 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
|
||||
|
||||
@@ -28,7 +31,7 @@ public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a set of MySQL/MariaDB connection parameters.
|
||||
/// 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.
|
||||
@@ -37,6 +40,7 @@ public struct Configuration: Sendable {
|
||||
/// - 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,
|
||||
@@ -44,15 +48,17 @@ public struct Configuration: Sendable {
|
||||
username: String,
|
||||
password: String,
|
||||
tls: TLS,
|
||||
maxConnectionsPerEventLoop: Int
|
||||
maxConnectionsPerEventLoop: Int,
|
||||
poolTimeout: Duration
|
||||
) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.name = name
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.tls = tls
|
||||
self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop
|
||||
self.name = name
|
||||
self.password = password
|
||||
self.poolTimeout = poolTimeout
|
||||
self.port = port
|
||||
self.tls = tls
|
||||
self.username = username
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Logging
|
||||
import MySQLNIO
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import PostgresNIO
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
@@ -13,46 +12,81 @@ import Testing
|
||||
)
|
||||
struct TLSTests {
|
||||
|
||||
// MARK: Properties tests
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `off has no TLS configuration`() {
|
||||
#expect(TLS.off.tlsConfiguration == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
TLS.prefer,
|
||||
TLS.require
|
||||
])
|
||||
func `maps to the default client configuration`(
|
||||
for tls: TLS
|
||||
) throws {
|
||||
let configuration = try #require(tls.tlsConfiguration)
|
||||
|
||||
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `prefer falls back to plaintext when the server offers no TLS`() async throws {
|
||||
// The fake server never advertises `CLIENT_SSL`, so this connection can only succeed by downgrading to
|
||||
// plaintext — pinning the driver behavior the `prefer` posture relies on.
|
||||
let server = try await PlaintextMySQLServer.start()
|
||||
let tlsConfiguration = try #require(TLS.prefer.tlsConfiguration)
|
||||
let connection = try await MySQLConnection.connect(
|
||||
to: .init(ipAddress: "127.0.0.1", port: server.port),
|
||||
username: "loud",
|
||||
database: "loud",
|
||||
tlsConfiguration: tlsConfiguration,
|
||||
logger: Logger(label: "test"),
|
||||
on: MultiThreadedEventLoopGroup.singleton.any()
|
||||
).get()
|
||||
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().get()
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ProbeTests {
|
||||
|
||||
@Test
|
||||
func `reports a reachable database`() async throws {
|
||||
let service = Service(
|
||||
let service = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
@@ -33,8 +33,8 @@ struct ProbeTests {
|
||||
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 = Service(
|
||||
driver: .mysql(
|
||||
let service = try Service(
|
||||
driver: .postgres(
|
||||
.init(
|
||||
host: "127.0.0.1",
|
||||
port: 1,
|
||||
@@ -42,7 +42,8 @@ struct ProbeTests {
|
||||
username: "nobody",
|
||||
password: "nothing",
|
||||
tls: .off,
|
||||
maxConnectionsPerEventLoop: 1
|
||||
maxConnectionsPerEventLoop: 1,
|
||||
poolTimeout: .seconds(10)
|
||||
)
|
||||
),
|
||||
logger: Logger(label: "test")
|
||||
@@ -57,6 +58,47 @@ struct ProbeTests {
|
||||
#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"))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ServiceTests {
|
||||
|
||||
@Test
|
||||
func `registers an SQLite database as the default for the in-memory driver`() async throws {
|
||||
let service = Service(
|
||||
let service = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
@@ -31,19 +31,20 @@ struct ServiceTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `registers a MySQL database as the default for the mysql driver`() async throws {
|
||||
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 = Service(
|
||||
driver: .mysql(
|
||||
let service = try Service(
|
||||
driver: .postgres(
|
||||
.init(
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
port: 5432,
|
||||
name: "site",
|
||||
username: "site",
|
||||
password: "site",
|
||||
tls: .off,
|
||||
maxConnectionsPerEventLoop: 1
|
||||
maxConnectionsPerEventLoop: 1,
|
||||
poolTimeout: .seconds(10)
|
||||
)
|
||||
),
|
||||
logger: Logger(label: "test")
|
||||
@@ -56,12 +57,12 @@ struct ServiceTests {
|
||||
|
||||
let dialect = try #require(database?.dialect)
|
||||
|
||||
#expect(dialect.name == "mysql")
|
||||
#expect(dialect.name == "postgresql")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `builds a usable in-memory database`() async throws {
|
||||
let service = Service(
|
||||
let service = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
@@ -87,12 +88,12 @@ struct ServiceTests {
|
||||
}
|
||||
|
||||
@Test(
|
||||
"mysql: migrate, insert, read back",
|
||||
.enabled(if: mysqlDriver != nil)
|
||||
"postgres: migrate, insert, read back",
|
||||
.enabled(if: postgresDriver != nil)
|
||||
)
|
||||
func mysqlRoundTrip() async throws {
|
||||
func postgresRoundTrip() async throws {
|
||||
try await roundTrip(
|
||||
driver: mysqlDriver!,
|
||||
driver: postgresDriver!,
|
||||
revertAfter: true
|
||||
)
|
||||
}
|
||||
@@ -116,11 +117,11 @@ private extension ServiceTests {
|
||||
revertAfter: Bool = false
|
||||
) async throws {
|
||||
let prepareDB = PrepareDB()
|
||||
let service = Service(
|
||||
let service = try Service(
|
||||
driver: driver,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
|
||||
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
@@ -147,25 +148,26 @@ private extension ServiceTests {
|
||||
|
||||
}
|
||||
|
||||
/// The MySQL/MariaDB driver built from the `MYSQL_TEST_*` environment variables, or `nil` when the gate
|
||||
/// variable `MYSQL_TEST_HOST` is unset — in which case the MySQL integration test is skipped, so the suite
|
||||
/// stays runnable with no database available.
|
||||
private let mysqlDriver: Persistence.Driver? = {
|
||||
/// 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["MYSQL_TEST_HOST"] else {
|
||||
guard let host = environment["POSTGRES_TEST_HOST"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .mysql(
|
||||
return .postgres(
|
||||
.init(
|
||||
host: host,
|
||||
port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306,
|
||||
name: environment["MYSQL_TEST_NAME"] ?? "site",
|
||||
username: environment["MYSQL_TEST_USERNAME"] ?? "site",
|
||||
password: environment["MYSQL_TEST_PASSWORD"] ?? "site",
|
||||
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
|
||||
maxConnectionsPerEventLoop: 2,
|
||||
poolTimeout: .seconds(10)
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake.
|
||||
///
|
||||
/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and
|
||||
/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only
|
||||
/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
|
||||
/// downgrades to plaintext rather than refusing the connection.
|
||||
final class PlaintextMySQLServer {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The port the server listens on, assigned by the system at bind time.
|
||||
let port: Int
|
||||
|
||||
/// The listening channel the server accepts connections through.
|
||||
private let channel: Channel
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
private init(
|
||||
channel: Channel,
|
||||
port: Int
|
||||
) {
|
||||
self.channel = channel
|
||||
self.port = port
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Starts a server on the loopback interface, on a system-assigned port.
|
||||
/// - Returns: the running server, ready to be connected to at ``port``.
|
||||
static func start() async throws -> PlaintextMySQLServer {
|
||||
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.addHandler(Handler())
|
||||
}
|
||||
}
|
||||
.bind(host: "127.0.0.1", port: 0)
|
||||
.get()
|
||||
|
||||
guard let port = channel.localAddress?.port else {
|
||||
throw ChannelError.unknownLocalAddress
|
||||
}
|
||||
|
||||
return .init(
|
||||
channel: channel,
|
||||
port: port
|
||||
)
|
||||
}
|
||||
|
||||
/// Stops the server, closing its listening channel.
|
||||
func stop() async throws {
|
||||
try await channel.close().get()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
|
||||
private extension PlaintextMySQLServer {
|
||||
|
||||
/// Greets a freshly accepted connection, accepts whatever authentication response arrives,
|
||||
/// and closes on anything after that (e.g. a `COM_QUIT`).
|
||||
final class Handler: ChannelInboundHandler {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias InboundIn = ByteBuffer
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether the client's handshake response has already been answered with an OK packet.
|
||||
private var didAuthenticate = false
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func channelActive(context: ChannelHandlerContext) {
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
}
|
||||
|
||||
func channelRead(
|
||||
context: ChannelHandlerContext,
|
||||
data: NIOAny
|
||||
) {
|
||||
guard didAuthenticate else {
|
||||
didAuthenticate = true
|
||||
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.ok(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
context.close(promise: nil)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
/// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet.
|
||||
///
|
||||
/// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`,
|
||||
/// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`,
|
||||
/// so the client cannot upgrade the connection to TLS.
|
||||
private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||
var payload = allocator.buffer(capacity: 80)
|
||||
|
||||
payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version
|
||||
payload.writeNullTerminatedString("8.0.0") // server version
|
||||
payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id
|
||||
payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1
|
||||
payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler
|
||||
payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION
|
||||
payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8)
|
||||
payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit)
|
||||
payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH
|
||||
payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length
|
||||
payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved
|
||||
payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2
|
||||
payload.writeNullTerminatedString("mysql_native_password") // auth plugin name
|
||||
|
||||
return framed(payload, sequence: 0, allocator: allocator)
|
||||
}
|
||||
|
||||
/// A bare OK packet, framed as the reply to the client's handshake response.
|
||||
private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||
var payload = allocator.buffer(capacity: 8)
|
||||
|
||||
payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings
|
||||
|
||||
return framed(payload, sequence: 2, allocator: allocator)
|
||||
}
|
||||
|
||||
/// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte.
|
||||
private static func framed(
|
||||
_ payload: ByteBuffer,
|
||||
sequence: UInt8,
|
||||
allocator: ByteBufferAllocator
|
||||
) -> ByteBuffer {
|
||||
var packet = allocator.buffer(capacity: payload.readableBytes + 4)
|
||||
var payload = payload
|
||||
|
||||
packet.writeInteger(UInt8(payload.readableBytes & 0xff))
|
||||
packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff))
|
||||
packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff))
|
||||
packet.writeInteger(sequence)
|
||||
packet.writeBuffer(&payload)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
|
||||
## Overview
|
||||
The package provides, grouped by role:
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
|
||||
@@ -23,9 +22,9 @@ Tests/
|
||||
```
|
||||
|
||||
## Testing
|
||||
Every suite carries a tag naming the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||
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 `Infrastructure`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- No package dependencies — Foundation only.
|
||||
|
||||
Reference in New Issue
Block a user