Initial commit.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Persistence"
|
||||
BuildableName = "Persistence"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PersistenceTests"
|
||||
BuildableName = "PersistenceTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Persistence"
|
||||
BuildableName = "Persistence"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}()
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import FluentKit
|
||||
|
||||
struct NotSQLConfiguration: DatabaseConfiguration {
|
||||
|
||||
var middleware: [any AnyModelMiddleware] = []
|
||||
|
||||
func makeDriver(
|
||||
for databases: Databases
|
||||
) -> any DatabaseDriver {
|
||||
NotSQLDriver()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func execute(
|
||||
schema: DatabaseSchema
|
||||
) -> EventLoopFuture<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func execute(
|
||||
enum: DatabaseEnum
|
||||
) -> EventLoopFuture<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func transaction<T>(
|
||||
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
|
||||
) -> EventLoopFuture<T> {
|
||||
closure(self)
|
||||
}
|
||||
|
||||
func withConnection<T>(
|
||||
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
|
||||
) -> EventLoopFuture<T> {
|
||||
closure(self)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import FluentKit
|
||||
|
||||
struct NotSQLDriver: DatabaseDriver {
|
||||
|
||||
func makeDatabase(
|
||||
with context: DatabaseContext
|
||||
) -> any Database {
|
||||
NotSQLDatabase(context: context)
|
||||
}
|
||||
|
||||
func shutdown() {}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user