From 3cc7fe9926e643279a11b0af9bb13cd1ef84f239 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Tue, 3 Feb 2026 09:41:31 -0500 Subject: [PATCH] feat: Updates environment variables and datbase to allow postgres configuration for production environments. --- .devcontainer/devcontainer.json | 7 +- Package.resolved | 29 ++++- Package.swift | 8 +- Public/css/output.css | 6 -- .../Middleware/DependenciesMiddleware.swift | 5 + Sources/App/configure.swift | 52 +++++++-- Sources/App/entrypoint.swift | 11 +- Sources/EnvClient/Interface.swift | 74 ------------- Sources/EnvVars/Interface.swift | 100 ++++++++++++++++++ Sources/PdfClient/Interface.swift | 9 +- Tests/DatabaseClientTests/Helpers.swift | 2 +- 11 files changed, 202 insertions(+), 101 deletions(-) delete mode 100644 Sources/EnvClient/Interface.swift create mode 100644 Sources/EnvVars/Interface.swift diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 25a757c..2a6887b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "name": "swift-manual-d-dev", "image": "git.housh.dev/michael/swift-dev-container:latest", "features": { - "ghcr.io/devcontainers/features/sshd:1": {}, + //"ghcr.io/devcontainers/features/sshd:1": {}, "ghcr.io/devcontainers/features/git:1": { "version": "os-provided", "ppa": "false" @@ -14,7 +14,7 @@ "ghcr.io/rocker-org/devcontainer-features/pandoc:1": {}, //"ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/wxw-matt/devcontainer-features/apt:latest": { - "packages": "weasyprint gnupg2" + "packages": "weasyprint gnupg2 tmux" } }, "runArgs": [ @@ -22,6 +22,9 @@ "--security-opt", "seccomp=unconfined" ], + "remoteEnv": { + "TERM": "xterm-256color" + }, "remoteUser": "swift", "forwardPorts": [8080], } diff --git a/Package.resolved b/Package.resolved index 5b1505d..de67425 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ed354a8e92f6b810403986d192b495a0e8e67cc9577e8ec24bce4ba275c0513d", + "originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00", "pins" : [ { "identity" : "async-http-client", @@ -73,6 +73,15 @@ "version" : "1.53.0" } }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69", + "version" : "2.12.0" + } + }, { "identity" : "fluent-sqlite-driver", "kind" : "remoteSourceControl", @@ -100,6 +109,24 @@ "version" : "0.14.0" } }, + { + "identity" : "postgres-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-kit.git", + "state" : { + "revision" : "7c079553e9cda74811e627775bf22e40a9405ad9", + "version" : "2.15.1" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6", + "version" : "1.30.1" + } + }, { "identity" : "routing-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 225e92c..234d501 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .executable(name: "App", targets: ["App"]), .library(name: "AuthClient", targets: ["AuthClient"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]), - .library(name: "EnvClient", targets: ["EnvClient"]), + .library(name: "EnvVars", targets: ["EnvVars"]), .library(name: "FileClient", targets: ["FileClient"]), .library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]), .library(name: "PdfClient", targets: ["PdfClient"]), @@ -22,6 +22,7 @@ let package = Package( .package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"), .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"), @@ -44,6 +45,7 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Fluent", package: "fluent"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), @@ -81,7 +83,7 @@ let package = Package( ] ), .target( - name: "EnvClient", + name: "EnvVars", dependencies: [ .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), @@ -105,7 +107,7 @@ let package = Package( .target( name: "PdfClient", dependencies: [ - .target(name: "EnvClient"), + .target(name: "EnvVars"), .target(name: "FileClient"), .target(name: "ManualDCore"), .product(name: "Dependencies", package: "swift-dependencies"), diff --git a/Public/css/output.css b/Public/css/output.css index 14f43d5..cca6d5f 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -7,12 +7,7 @@ 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --color-red-500: oklch(63.7% 0.237 25.331); - --color-red-600: oklch(57.7% 0.245 27.325); --color-green-400: oklch(79.2% 0.209 151.711); - --color-indigo-600: oklch(51.1% 0.262 276.966); - --color-slate-300: oklch(86.9% 0.022 252.894); - --color-slate-900: oklch(20.8% 0.042 265.755); --color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-400: oklch(70.7% 0.022 261.325); --color-black: #000; @@ -30,7 +25,6 @@ --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-bold: 700; --radius-sm: 0.25rem; - --radius-md: 0.375rem; --radius-lg: 0.5rem; --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); diff --git a/Sources/App/Middleware/DependenciesMiddleware.swift b/Sources/App/Middleware/DependenciesMiddleware.swift index 1d83091..9a9b03c 100644 --- a/Sources/App/Middleware/DependenciesMiddleware.swift +++ b/Sources/App/Middleware/DependenciesMiddleware.swift @@ -2,6 +2,7 @@ import AuthClient import DatabaseClient import Dependencies +import EnvVars import ManualDCore import PdfClient import Vapor @@ -14,16 +15,19 @@ struct DependenciesMiddleware: AsyncMiddleware { private let values: DependencyValues.Continuation // private let apiController: ApiController private let database: DatabaseClient + private let environment: EnvVars private let viewController: ViewController init( database: DatabaseClient, + environment: EnvVars, // apiController: ApiController = .liveValue, viewController: ViewController = .liveValue ) { self.values = withEscapedDependencies { $0 } // self.apiController = apiController self.database = database + self.environment = environment self.viewController = viewController } @@ -33,6 +37,7 @@ struct DependenciesMiddleware: AsyncMiddleware { // $0.apiController = apiController $0.auth = .live(on: request) $0.database = database + $0.environment = environment // $0.dateFormatter = .liveValue $0.viewController = viewController $0.pdfClient = .liveValue diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 7241f2b..22b0e18 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,7 +1,9 @@ import DatabaseClient import Dependencies import Elementary +import EnvVars import Fluent +import FluentPostgresDriver import FluentSQLiteDriver import ManualDCore import NIOSSL @@ -14,12 +16,15 @@ import ViewController // configures your application public func configure( _ app: Application, + in environment: EnvVars, makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) } ) async throws { // Setup the database client. - let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient) + let databaseClient = try await setupDatabase( + on: app, environment: environment, factory: makeDatabaseClient + ) // Add the global middlewares. - addMiddleware(to: app, database: databaseClient) + addMiddleware(to: app, database: databaseClient, environment: environment) #if DEBUG // Live reload of the application for development when launched with the `./swift-dev` command // app.lifecycle.use(BrowserSyncHandler()) @@ -33,7 +38,11 @@ public func configure( addCommands(to: app) } -private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) { +private func addMiddleware( + to app: Application, + database databaseClient: DatabaseClient, + environment: EnvVars +) { // cors middleware should come before default error middleware using `at: .beginning` let corsConfiguration = CORSMiddleware.Configuration( allowedOrigin: .all, @@ -48,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(app.sessions.middleware) - app.middleware.use(DependenciesMiddleware(database: databaseClient)) + app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment)) } private func setupDatabase( on app: Application, + environment: EnvVars, factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient ) async throws -> DatabaseClient { switch app.environment { - case .production, .development: - let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite" + case .production: + let configuration = try environment.postgresConfiguration() + app.databases.use(.postgres(configuration: configuration), as: .psql) + case .development: + let dbFileName = environment.sqlitePath ?? "db.sqlite" app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite) default: app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite) @@ -129,3 +142,30 @@ private func siteHandler( return try await viewController.respond(route: route, request: request) } } + +extension EnvVars { + func postgresConfiguration() throws -> SQLPostgresConfiguration { + guard let hostname = postgresHostname, + let username = postgresUsername, + let password = postgresPassword, + let database = postgresDatabase + else { + throw EnvError("Missing environment variables for postgres connection.") + } + return .init( + hostname: hostname, + username: username, + password: password, + database: database, + tls: .disable + ) + } +} + +struct EnvError: Error { + let reason: String + + init(_ reason: String) { + self.reason = reason + } +} diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift index f85b7d8..7e03608 100644 --- a/Sources/App/entrypoint.swift +++ b/Sources/App/entrypoint.swift @@ -1,5 +1,6 @@ import DatabaseClient import Dependencies +import EnvVars import Logging import NIOCore import NIOPosix @@ -17,11 +18,15 @@ enum Entrypoint { // You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency. // Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down. // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures. - // let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() - // app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) + let executorTakeoverSuccess = + NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() + app.logger.debug( + "Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", + metadata: ["success": .stringConvertible(executorTakeoverSuccess)] + ) do { - try await configure(app) + try await configure(app, in: EnvVars.live()) } catch { app.logger.report(error: error) try? await app.asyncShutdown() diff --git a/Sources/EnvClient/Interface.swift b/Sources/EnvClient/Interface.swift deleted file mode 100644 index b673dfa..0000000 --- a/Sources/EnvClient/Interface.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Dependencies -import DependenciesMacros -import Foundation - -extension DependencyValues { - - /// Holds values defined in the process environment that are needed. - /// - /// These are generally loaded from a `.env` file, but also have default values, - /// if not found. - public var env: @Sendable () throws -> EnvVars { - get { self[EnvClient.self].env } - set { self[EnvClient.self].env = newValue } - } -} - -@DependencyClient -struct EnvClient: Sendable { - public var env: @Sendable () throws -> EnvVars -} - -/// Holds values defined in the process environment that are needed. -/// -/// These are generally loaded from a `.env` file, but also have default values, -/// if not found. -public struct EnvVars: Codable, Equatable, Sendable { - - /// The path to the pandoc executable on the system, used to generate pdf's. - public let pandocPath: String - - /// The pdf engine to use with pandoc when creating pdf's. - public let pdfEngine: String - - public init( - pandocPath: String = "/usr/bin/pandoc", - pdfEngine: String = "weasyprint" - ) { - self.pandocPath = pandocPath - self.pdfEngine = pdfEngine - } - - enum CodingKeys: String, CodingKey { - case pandocPath = "PANDOC_PATH" - case pdfEngine = "PDF_ENGINE" - } - -} - -extension EnvClient: DependencyKey { - static let testValue = Self() - - static let liveValue = Self(env: { - // Convert default values into a dictionary. - let defaults = - (try? encoder.encode(EnvVars())) - .flatMap { try? decoder.decode([String: String].self, from: $0) } - ?? [:] - - // Merge the default values with values found in process environment. - let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 }) - - return (try? JSONSerialization.data(withJSONObject: assigned)) - .flatMap { try? decoder.decode(EnvVars.self, from: $0) } - ?? .init() - }) -} - -private let encoder: JSONEncoder = { - JSONEncoder() -}() - -private let decoder: JSONDecoder = { - JSONDecoder() -}() diff --git a/Sources/EnvVars/Interface.swift b/Sources/EnvVars/Interface.swift new file mode 100644 index 0000000..2b82d88 --- /dev/null +++ b/Sources/EnvVars/Interface.swift @@ -0,0 +1,100 @@ +import Dependencies +import DependenciesMacros +import Foundation + +extension DependencyValues { + + /// Holds values defined in the process environment that are needed. + /// + /// These are generally loaded from a `.env` file, but also have default values, + /// if not found. + public var environment: EnvVars { + get { self[EnvVars.self] } + set { self[EnvVars.self] = newValue } + } +} + +/// Holds values defined in the process environment that are needed. +/// +/// These are generally loaded from a `.env` file, but also have default values, +/// if not found. +public struct EnvVars: Codable, Equatable, Sendable { + + /// The path to the pandoc executable on the system, used to generate pdf's. + public let pandocPath: String + + /// The pdf engine to use with pandoc when creating pdf's. + public let pdfEngine: String + + /// The postgres hostname, used for production database connection. + public let postgresHostname: String? + + /// The postgres username, used for production database connection. + public let postgresUsername: String? + + /// The postgres password, used for production database connection. + public let postgresPassword: String? + + /// The postgres database, used for production database connection. + public let postgresDatabase: String? + + /// The path to the sqlite database, used for development database connection. + public let sqlitePath: String? + + public init( + pandocPath: String = "/usr/bin/pandoc", + pdfEngine: String = "weasyprint", + postgresHostname: String? = "localhost", + postgresUsername: String? = "vapor", + postgresPassword: String? = "super-secret", + postgresDatabase: String? = "vapor", + sqlitePath: String? = "db.sqlite" + ) { + self.pandocPath = pandocPath + self.pdfEngine = pdfEngine + self.postgresHostname = postgresHostname + self.postgresUsername = postgresUsername + self.postgresPassword = postgresPassword + self.postgresDatabase = postgresDatabase + self.sqlitePath = sqlitePath + } + + enum CodingKeys: String, CodingKey { + case pandocPath = "PANDOC_PATH" + case pdfEngine = "PDF_ENGINE" + case postgresHostname = "POSTGRES_HOSTNAME" + case postgresUsername = "POSTGRES_USERNAME" + case postgresPassword = "POSTGRES_PASSWORD" + case postgresDatabase = "POSTGRES_DATABASE" + case sqlitePath = "SQLITE_PATH" + } + + public static func live(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Self { + + // Convert default values into a dictionary. + let defaults = + (try? encoder.encode(EnvVars())) + .flatMap { try? decoder.decode([String: String].self, from: $0) } + ?? [:] + + // Merge the default values with values found in process environment. + let assigned = defaults.merging(env, uniquingKeysWith: { $1 }) + + return (try? JSONSerialization.data(withJSONObject: assigned)) + .flatMap { try? decoder.decode(EnvVars.self, from: $0) } + ?? .init() + } + +} + +extension EnvVars: TestDependencyKey { + public static let testValue = Self() +} + +private let encoder: JSONEncoder = { + JSONEncoder() +}() + +private let decoder: JSONDecoder = { + JSONDecoder() +}() diff --git a/Sources/PdfClient/Interface.swift b/Sources/PdfClient/Interface.swift index a995068..aabfcf2 100644 --- a/Sources/PdfClient/Interface.swift +++ b/Sources/PdfClient/Interface.swift @@ -1,7 +1,7 @@ import Dependencies import DependenciesMacros import Elementary -import EnvClient +import EnvVars import FileClient import Foundation import ManualDCore @@ -46,9 +46,8 @@ extension PdfClient: DependencyKey { }, generatePdf: { projectID, html in @Dependency(\.fileClient) var fileClient - @Dependency(\.env) var env + @Dependency(\.environment) var environment - let envVars = try env() let baseUrl = "/tmp/\(projectID)" try await fileClient.writeFile(html.render(), "\(baseUrl).html") @@ -57,10 +56,10 @@ extension PdfClient: DependencyKey { let standardOutput = Pipe() process.standardInput = standardInput process.standardOutput = standardOutput - process.executableURL = URL(fileURLWithPath: envVars.pandocPath) + process.executableURL = URL(fileURLWithPath: environment.pandocPath) process.arguments = [ "\(baseUrl).html", - "--pdf-engine=\(envVars.pdfEngine)", + "--pdf-engine=\(environment.pdfEngine)", "--from=html", "--css=Public/css/pdf.css", "--output=\(baseUrl).pdf", diff --git a/Tests/DatabaseClientTests/Helpers.swift b/Tests/DatabaseClientTests/Helpers.swift index a3f680f..f552794 100644 --- a/Tests/DatabaseClientTests/Helpers.swift +++ b/Tests/DatabaseClientTests/Helpers.swift @@ -15,7 +15,7 @@ func withDatabase( ) async throws { let app = try await Application.make(.testing) do { - try await configure(app) + try await configure(app, in: .live()) let database = app.db try await app.autoMigrate()