diff --git a/docs/docs/sql/database.md b/docs/docs/sql/database.md index c34098b7..456e5679 100644 --- a/docs/docs/sql/database.md +++ b/docs/docs/sql/database.md @@ -12,6 +12,7 @@ run in `ZIO[Database, QueryError, …]`; you supply a `Database` via its `ZLayer final case class DbConfig( target: DbConfig.Target, // database, host, port credentials: Option[DbConfig.Credentials], // username, password + connection: DbConfig.Connection, // ssl, timeouts, application name, extra props pool: DbConfig.Pool, // minConnections, maxConnections, duration logging: DbConfig.Logging, // queryLogLevel, logSql execution: DbConfig.Execution, // result-buffer tuning @@ -24,6 +25,7 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve { "target": { "database": "oxygen_example", "host": "localhost", "port": 5210 }, "credentials": { "username": "oxygen_username", "password": "oxygen_password" }, + "connection": { "sslMode": "prefer", "connectTimeout": "PT10S", "applicationName": "oxygen_example" }, "pool": { "minConnections": 2, "maxConnections": 16, "duration": "PT5M" }, "logging": { "queryLogLevel": "Trace", "logSql": true }, "execution": { "bufferChunkSize": [16, 64, 64, 256], "bufferNumChunks": 2 } @@ -35,6 +37,28 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve - `DbConfig.Execution.default` provides sensible buffer defaults. - Credentials are marked `@jsonSecret`, so they're redacted in safe serialization. +### JDBC connection settings — `DbConfig.Connection` + +Optional, typed JDBC connection params, translated into the driver `Properties` alongside +`user`/`password`. Every field is optional; `DbConfig.Connection.default` is all-empty, which +preserves the historical behavior of supplying only credentials, so `connection` can be omitted +entirely from the JSON. + +| Field | JDBC property | Notes | +|-------|---------------|-------| +| `sslMode` | `sslmode` | One of `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` (Postgres `sslmode`). | +| `sslRootCert` | `sslrootcert` | Path to the trusted CA cert — typically required for `verify-ca` / `verify-full`. | +| `sslCert` | `sslcert` | Path to the client cert (mutual TLS). | +| `sslKey` | `sslkey` | Path to the client key **file** (a path, not the key material). | +| `connectTimeout` | `connectTimeout` | `Duration`, sent as whole seconds. | +| `socketTimeout` | `socketTimeout` | `Duration`, sent as whole seconds. | +| `applicationName` | `ApplicationName` | Shows up in `pg_stat_activity`. | +| `extraProperties` | *(verbatim)* | `Map[String, String]` escape hatch for any other driver knob. | + +Property values are Postgres (`pgjdbc`) flavored. `extraProperties` are applied **last**, so they +override the typed properties (and credentials) on a key collision — the escape hatch always wins. +So `"ssl prefer"` from the ticket is simply `"connection": { "sslMode": "prefer" }`. + ## The Database layer `Database.layer: URLayer[DbConfig, Database]` wires the driver, connection pool, and base database diff --git a/modules/sql/core/src/main/scala/oxygen/sql/Database.scala b/modules/sql/core/src/main/scala/oxygen/sql/Database.scala index 72ea2d56..27756865 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/Database.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/Database.scala @@ -44,7 +44,7 @@ object Database { def make(config: DbConfig): URIO[Scope, Database] = for { driver <- Driver.makePSQL - connect = driver.getConnection(config.target, config.credentials) + connect = driver.getConnection(config.target, config.credentials, config.connection) pool <- ConnectionPool.makeZPool(connect, config.pool) logConfigRef <- FiberRef.make(config.logging) connectionStateRef <- FiberRef.make[ConnectionState](ConnectionState.Pool(pool)) @@ -65,6 +65,7 @@ object Database { ZLayer.makeSome[DbConfig, Database]( ZLayer.service[DbConfig].project(_.target), ZLayer.service[DbConfig].project(_.credentials), + ZLayer.service[DbConfig].project(_.connection), ZLayer.service[DbConfig].project(_.pool), ZLayer.service[DbConfig].project(_.logging), ZLayer.service[DbConfig].project(_.execution), diff --git a/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala b/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala index 4a3acae7..f4023e8d 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala @@ -1,5 +1,6 @@ package oxygen.sql +import oxygen.core.typeclass.StrictEnum import oxygen.json.jsonSecret import oxygen.predef.core.* import oxygen.schema.JsonSchema @@ -9,6 +10,7 @@ import zio.* final case class DbConfig( target: DbConfig.Target, credentials: Nullable[DbConfig.Credentials], + connection: DbConfig.Connection = DbConfig.Connection.default, pool: DbConfig.Pool = DbConfig.Pool.default, logging: DbConfig.Logging = DbConfig.Logging.default, execution: DbConfig.Execution = DbConfig.Execution.default, @@ -29,6 +31,70 @@ object DbConfig { password: String, ) derives JsonSchema + /** + * Optional JDBC connection settings, translated into the driver `Properties` alongside `user`/`password`. + * + * Every field is optional and defaults to "unset" ([[Connection.default]]), which preserves the historical + * behavior of only supplying credentials. Property names/values below are Postgres (`pgjdbc`) flavored; other + * dialects can specialize this translation later. + * + * @param sslMode Postgres `sslmode` (see [[SslMode]]). + * @param sslRootCert Path to the trusted CA cert (`sslrootcert`) — typically required for `verify-ca`/`verify-full`. + * @param sslCert Path to the client cert (`sslcert`) for mutual TLS. + * @param sslKey Path to the client key file (`sslkey`) for mutual TLS. A file path, not the key material itself. + * @param connectTimeout Max time to establish the TCP connection (`connectTimeout`, whole seconds). + * @param socketTimeout Max time a socket read may block (`socketTimeout`, whole seconds). + * @param applicationName Reported to the server as `ApplicationName` (shows up in `pg_stat_activity`). + * @param extraProperties Escape hatch: arbitrary driver properties copied verbatim. Applied last, so these + * override the typed properties (and credentials) on key collision. + */ + final case class Connection( + sslMode: Option[SslMode] = None, + sslRootCert: Option[String] = None, + sslCert: Option[String] = None, + sslKey: Option[String] = None, + connectTimeout: Option[Duration] = None, + socketTimeout: Option[Duration] = None, + applicationName: Option[String] = None, + extraProperties: Map[String, String] = Map.empty, + ) derives JsonSchema { + + /** Typed settings translated into `(key, value)` JDBC property pairs, followed by the verbatim escape-hatch props. */ + def properties: Seq[(String, String)] = + Seq( + sslMode.map(m => "sslmode" -> m.sslmode), + sslRootCert.map("sslrootcert" -> _), + sslCert.map("sslcert" -> _), + sslKey.map("sslkey" -> _), + connectTimeout.map(d => "connectTimeout" -> d.toSeconds.toString), + socketTimeout.map(d => "socketTimeout" -> d.toSeconds.toString), + applicationName.map("ApplicationName" -> _), + ).flatten ++ extraProperties.toSeq + + } + object Connection { + val default: Connection = Connection() + } + + /** + * Postgres `sslmode`. Encoded/decoded (case-insensitively) using the exact libpq spellings: + * `disable, allow, prefer, require, verify-ca, verify-full`. + * + * See: https://jdbc.postgresql.org/documentation/ssl/ and the libpq `sslmode` docs. + */ + enum SslMode(final val sslmode: String) { + case Disable extends SslMode("disable") + case Allow extends SslMode("allow") + case Prefer extends SslMode("prefer") + case Require extends SslMode("require") + case VerifyCa extends SslMode("verify-ca") + case VerifyFull extends SslMode("verify-full") + } + object SslMode { + given StrictEnum[SslMode] = StrictEnum.derive[SslMode](_.sslmode) + given JsonSchema[SslMode] = JsonSchema.fromPlainText + } + final case class Pool( minConnections: Int = 2, maxConnections: Int = 8, diff --git a/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala b/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala index cc73ba26..c7276086 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala @@ -5,7 +5,7 @@ import zio.* trait Driver { - def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection + def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection } object Driver { @@ -21,14 +21,30 @@ object Driver { final case class JdbcDriver(dbUrlPrefix: String, driver: java.sql.Driver) extends Driver { - override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection = { + override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection = + Driver.GetConnection { + Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), JdbcDriver.buildProperties(credentials, connection)) } + } + + } + object JdbcDriver { + + /** + * Assemble the JDBC `Properties` passed to `driver.connect`. + * + * Application order (later wins on key collision): + * 1. `user`/`password` from credentials + * 2. typed connection settings ([[DbConfig.Connection]]) + * 3. `extraProperties` escape hatch (copied verbatim) + */ + def buildProperties(credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): java.util.Properties = { val props = new java.util.Properties credentials.foreach { credentials => props.put("user", credentials.username) props.put("password", credentials.password) } - - Driver.GetConnection { Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), props) } } + connection.properties.foreach { case (k, v) => props.put(k, v) } + props } } @@ -36,13 +52,14 @@ object Driver { final case class GetConnection(getConnection: ZIO[Scope, ConnectionError, Connection]) object GetConnection { - val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials], Driver.GetConnection] = + val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials] & DbConfig.Connection, Driver.GetConnection] = ZLayer.fromZIO { for { driver <- ZIO.service[Driver] target <- ZIO.service[DbConfig.Target] credentials <- ZIO.service[Option[DbConfig.Credentials]] - } yield driver.getConnection(target, credentials) + connection <- ZIO.service[DbConfig.Connection] + } yield driver.getConnection(target, credentials, connection) } } diff --git a/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala b/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala new file mode 100644 index 00000000..a541fdd4 --- /dev/null +++ b/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala @@ -0,0 +1,156 @@ +package oxygen.sql + +import oxygen.predef.test.* +import oxygen.schema.JsonSchema +import scala.jdk.CollectionConverters.* +import zio.* + +object DbConfigSpec extends OxygenSpecDefault { + + private def propsToMap(p: java.util.Properties): Map[String, String] = + p.stringPropertyNames().asScala.iterator.map(k => k -> p.getProperty(k)).toMap + + private def sslModeSpec: TestSpec = + suite("SslMode")( + test("encodes to postgres sslmode spellings") { + assertTrue( + DbConfig.SslMode.Disable.sslmode == "disable", + DbConfig.SslMode.Allow.sslmode == "allow", + DbConfig.SslMode.Prefer.sslmode == "prefer", + DbConfig.SslMode.Require.sslmode == "require", + DbConfig.SslMode.VerifyCa.sslmode == "verify-ca", + DbConfig.SslMode.VerifyFull.sslmode == "verify-full", + ) + }, + test("decodes (case-insensitively) via JsonSchema") { + assertTrue( + JsonSchema[DbConfig.SslMode].decode("\"prefer\"") == Right(DbConfig.SslMode.Prefer), + JsonSchema[DbConfig.SslMode].decode("\"verify-full\"") == Right(DbConfig.SslMode.VerifyFull), + JsonSchema[DbConfig.SslMode].decode("\"REQUIRE\"") == Right(DbConfig.SslMode.Require), + JsonSchema[DbConfig.SslMode].decode("\"nonsense\"").isLeft, + ) + }, + ) + + private def propertiesSpec: TestSpec = + suite("Connection.properties")( + test("default is empty") { + assertTrue(DbConfig.Connection.default.properties.isEmpty) + }, + test("fully-populated typed settings translate to postgres property pairs") { + val conn = DbConfig.Connection( + sslMode = DbConfig.SslMode.Require.some, + sslRootCert = "/certs/root.crt".some, + sslCert = "/certs/client.crt".some, + sslKey = "/certs/client.key".some, + connectTimeout = 10.seconds.some, + socketTimeout = 30.seconds.some, + applicationName = "oxygen-app".some, + extraProperties = Map("tcpKeepAlive" -> "true"), + ) + assertTrue( + conn.properties.toMap == Map( + "sslmode" -> "require", + "sslrootcert" -> "/certs/root.crt", + "sslcert" -> "/certs/client.crt", + "sslkey" -> "/certs/client.key", + "connectTimeout" -> "10", + "socketTimeout" -> "30", + "ApplicationName" -> "oxygen-app", + "tcpKeepAlive" -> "true", + ), + ) + }, + ) + + private def buildPropertiesSpec: TestSpec = + suite("JdbcDriver.buildProperties")( + test("only user/password when connection is unset (preserves historical behavior)") { + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, DbConfig.Connection.default) + assertTrue(propsToMap(props) == Map("user" -> "u", "password" -> "p")) + }, + test("no credentials + no connection settings => empty props") { + val props = Driver.JdbcDriver.buildProperties(None, DbConfig.Connection.default) + assertTrue(propsToMap(props).isEmpty) + }, + test("credentials + typed connection settings are all present") { + val conn = DbConfig.Connection(sslMode = DbConfig.SslMode.Prefer.some, applicationName = "svc".some) + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn) + assertTrue( + propsToMap(props) == Map( + "user" -> "u", + "password" -> "p", + "sslmode" -> "prefer", + "ApplicationName" -> "svc", + ), + ) + }, + test("extraProperties are applied last and override typed + credentials on collision") { + val conn = DbConfig.Connection( + sslMode = DbConfig.SslMode.Prefer.some, + extraProperties = Map("sslmode" -> "require", "password" -> "override"), + ) + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn) + assertTrue( + props.getProperty("sslmode") == "require", + props.getProperty("password") == "override", + props.getProperty("user") == "u", + ) + }, + ) + + private def decodingSpec: TestSpec = + suite("JSON decoding")( + test("connection block decodes from JSON") { + val json = + """{ + | "sslMode": "verify-full", + | "sslRootCert": "/certs/root.crt", + | "connectTimeout": "PT10S", + | "applicationName": "oxygen-app", + | "extraProperties": { "tcpKeepAlive": "true" } + |}""".stripMargin + assertTrue( + JsonSchema[DbConfig.Connection].decode(json) == Right( + DbConfig.Connection( + sslMode = DbConfig.SslMode.VerifyFull.some, + sslRootCert = "/certs/root.crt".some, + connectTimeout = 10.seconds.some, + applicationName = "oxygen-app".some, + extraProperties = Map("tcpKeepAlive" -> "true"), + ), + ), + ) + }, + test("full DbConfig decodes with connection omitted (defaults to empty)") { + val json = + """{ + | "target": { "database": "db", "host": "localhost", "port": 5432 }, + | "credentials": { "username": "u", "password": "p" } + |}""".stripMargin + assertTrue(JsonSchema[DbConfig].decode(json).map(_.connection) == Right(DbConfig.Connection.default)) + }, + test("full DbConfig decodes with a connection block") { + val json = + """{ + | "target": { "database": "db", "host": "localhost", "port": 5432 }, + | "credentials": { "username": "u", "password": "p" }, + | "connection": { "sslMode": "require", "socketTimeout": "PT30S" } + |}""".stripMargin + assertTrue( + JsonSchema[DbConfig].decode(json).map(_.connection) == Right( + DbConfig.Connection(sslMode = DbConfig.SslMode.Require.some, socketTimeout = 30.seconds.some), + ), + ) + }, + ) + + override def testSpec: TestSpec = + suite("DbConfigSpec")( + sslModeSpec, + propertiesSpec, + buildPropertiesSpec, + decodingSpec, + ) + +} diff --git a/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala b/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala index cbbac285..ecb2a635 100644 --- a/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala +++ b/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala @@ -20,7 +20,7 @@ object PostgresTestContainer { password <- TestContainerService.randomAlphaString(10) database <- TestContainerService.randomAlphaString(10) - dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, pool, logging, execution) + dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, DbConfig.Connection.default, pool, logging, execution) container = TestContainer .make("postgres", "postgres", "latest")