From 9c0560a50125d2dc2537a6fc5f4ca53ea6c946ee Mon Sep 17 00:00:00 2001 From: "Herman J. Radtke III" Date: Wed, 17 Jan 2024 11:19:53 -0500 Subject: [PATCH] feat(pg-connection-string): ClientConfig helper functions Two new functions are introduced to make it easy for TypeScript users to use a PostgresSQL connection string with pg Client. Fixes #2280 --- packages/pg-connection-string/README.md | 21 +++ packages/pg-connection-string/index.d.ts | 5 + packages/pg-connection-string/index.js | 62 +++++++++ .../pg-connection-string/test/clientConfig.js | 125 ++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 packages/pg-connection-string/test/clientConfig.js diff --git a/packages/pg-connection-string/README.md b/packages/pg-connection-string/README.md index 8c18bd26d..d3a16881a 100644 --- a/packages/pg-connection-string/README.md +++ b/packages/pg-connection-string/README.md @@ -35,6 +35,27 @@ The resulting config contains a subset of the following properties: * `ca` * any other query parameters (for example, `application_name`) are preserved intact. +### ClientConfig Compatibility for TypeScript + +The pg-connection-string `ConnectionOptions` interface is not compatible with the `ClientConfig` interface that [pg.Client](https://node-postgres.com/apis/client) expects. To remedy this, use the `parseIntoClientConfig` function instead of `parse`: + +```ts +import { ClientConfig } from 'pg'; +import { parseIntoClientConfig } from 'pg-connection-string'; + +const config: ClientConfig = parseIntoClientConfig('postgres://someuser:somepassword@somehost:381/somedatabase') +``` + +You can also use `toClientConfig` to convert an existing `ConnectionOptions` interface into a `ClientConfig` interface: + +```ts +import { ClientConfig } from 'pg'; +import { parse, toClientConfig } from 'pg-connection-string'; + +const config = parse('postgres://someuser:somepassword@somehost:381/somedatabase') +const clientConfig: ClientConfig = toClientConfig(config) +``` + ## Connection Strings The short summary of acceptable URLs is: diff --git a/packages/pg-connection-string/index.d.ts b/packages/pg-connection-string/index.d.ts index 3081270e2..d68cc23d3 100644 --- a/packages/pg-connection-string/index.d.ts +++ b/packages/pg-connection-string/index.d.ts @@ -1,3 +1,5 @@ +import { ClientConfig } from 'pg' + export function parse(connectionString: string): ConnectionOptions export interface ConnectionOptions { @@ -13,3 +15,6 @@ export interface ConnectionOptions { fallback_application_name?: string options?: string } + +export function toClientConfig(config: ConnectionOptions): ClientConfig +export function parseIntoClientConfig(connectionString: string): ClientConfig diff --git a/packages/pg-connection-string/index.js b/packages/pg-connection-string/index.js index c7fc72a36..53c3859e5 100644 --- a/packages/pg-connection-string/index.js +++ b/packages/pg-connection-string/index.js @@ -107,6 +107,68 @@ function parse(str) { return config } +// convert pg-connection-string ssl config to a ClientConfig.ConnectionOptions +function toConnectionOptions(sslConfig) { + const connectionOptions = Object.entries(sslConfig).reduce((c, [key, value]) => { + // we explicitly check for undefined and null instead of `if (value)` because some + // options accept falsy values. Example: `ssl.rejectUnauthorized = false` + if (value !== undefined && value !== null) { + c[key] = value + } + + return c + }, {}) + + return connectionOptions +} + +// convert pg-connection-string config to a ClientConfig +function toClientConfig(config) { + const poolConfig = Object.entries(config).reduce((c, [key, value]) => { + if (key === 'ssl') { + const sslConfig = value + + if (typeof sslConfig === 'boolean') { + c[key] = sslConfig + } + // else path is taken. multiple tests produce a sslConfig that is an object + // and we can console.log to see that we take this path + // + // see https://github.com/istanbuljs/babel-plugin-istanbul/issues/186#issuecomment-1137765139 + // istanbul ignore else + else if (typeof sslConfig === 'object') { + c[key] = toConnectionOptions(sslConfig) + } + } else if (value !== undefined && value !== null) { + if (key === 'port') { + // when port is not specified, it is converted into an empty string + // we want to avoid NaN or empty string as a values in ClientConfig + if (value !== '') { + const v = parseInt(value, 10) + if (isNaN(v)) { + throw new Error(`Invalid ${key}: ${value}`) + } + + c[key] = v + } + } else { + c[key] = value + } + } + + return c + }, {}) + + return poolConfig +} + +// parses a connection string into ClientConfig +function parseIntoClientConfig(str) { + return toClientConfig(parse(str)) +} + module.exports = parse parse.parse = parse +parse.toClientConfig = toClientConfig +parse.parseIntoClientConfig = parseIntoClientConfig diff --git a/packages/pg-connection-string/test/clientConfig.js b/packages/pg-connection-string/test/clientConfig.js new file mode 100644 index 000000000..9dba2e21d --- /dev/null +++ b/packages/pg-connection-string/test/clientConfig.js @@ -0,0 +1,125 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +chai.should() + +const { parse, toClientConfig, parseIntoClientConfig } = require('../') + +describe('toClientConfig', function () { + it('converts connection info', function () { + const config = parse('postgres://brian:pw@boom:381/lala') + const clientConfig = toClientConfig(config) + + clientConfig.user.should.equal('brian') + clientConfig.password.should.equal('pw') + clientConfig.host.should.equal('boom') + clientConfig.port.should.equal(381) + clientConfig.database.should.equal('lala') + }) + + it('converts query params', function () { + const config = parse( + 'postgres:///?application_name=TheApp&fallback_application_name=TheAppFallback&client_encoding=utf8&options=-c geqo=off' + ) + const clientConfig = toClientConfig(config) + + clientConfig.application_name.should.equal('TheApp') + clientConfig.fallback_application_name.should.equal('TheAppFallback') + clientConfig.client_encoding.should.equal('utf8') + clientConfig.options.should.equal('-c geqo=off') + }) + + it('converts SSL boolean', function () { + const config = parse('pg:///?ssl=true') + const clientConfig = toClientConfig(config) + + clientConfig.ssl.should.equal(true) + }) + + it('converts sslmode=disable', function () { + const config = parse('pg:///?sslmode=disable') + const clientConfig = toClientConfig(config) + + clientConfig.ssl.should.equal(false) + }) + + it('converts sslmode=noverify', function () { + const config = parse('pg:///?sslmode=no-verify') + const clientConfig = toClientConfig(config) + + clientConfig.ssl.rejectUnauthorized.should.equal(false) + }) + + it('converts other sslmode options', function () { + const config = parse('pg:///?sslmode=verify-ca') + const clientConfig = toClientConfig(config) + + clientConfig.ssl.should.deep.equal({}) + }) + + it('converts other sslmode options', function () { + const config = parse('pg:///?sslmode=verify-ca') + const clientConfig = toClientConfig(config) + + clientConfig.ssl.should.deep.equal({}) + }) + + it('converts ssl cert options', function () { + const connectionString = + 'pg:///?sslcert=' + + __dirname + + '/example.cert&sslkey=' + + __dirname + + '/example.key&sslrootcert=' + + __dirname + + '/example.ca' + const config = parse(connectionString) + const clientConfig = toClientConfig(config) + + clientConfig.ssl.should.deep.equal({ + ca: 'example ca\n', + cert: 'example cert\n', + key: 'example key\n', + }) + }) + + it('converts unix domain sockets', function () { + const config = parse('socket:/some path/?db=my[db]&encoding=utf8&client_encoding=bogus') + const clientConfig = toClientConfig(config) + clientConfig.host.should.equal('/some path/') + clientConfig.database.should.equal('my[db]', 'must to be escaped and unescaped through "my%5Bdb%5D"') + clientConfig.client_encoding.should.equal('utf8') + }) + + it('handles invalid port', function () { + const config = parse('postgres://@boom:381/lala') + config.port = 'bogus' + expect(() => toClientConfig(config)).to.throw() + }) + + it('handles invalid sslconfig values', function () { + const config = parse('postgres://@boom/lala') + config.ssl = {} + config.ssl.cert = null + config.ssl.key = undefined + + const clientConfig = toClientConfig(config) + + clientConfig.host.should.equal('boom') + clientConfig.database.should.equal('lala') + clientConfig.ssl.should.deep.equal({}) + }) +}) + +describe('parseIntoClientConfig', function () { + it('converts url', function () { + const clientConfig = parseIntoClientConfig('postgres://brian:pw@boom:381/lala') + + clientConfig.user.should.equal('brian') + clientConfig.password.should.equal('pw') + clientConfig.host.should.equal('boom') + clientConfig.port.should.equal(381) + clientConfig.database.should.equal('lala') + }) +})