Skip to content

Commit d3f23d3

Browse files
authored
feat: new database connection options
1 parent ab45a15 commit d3f23d3

File tree

5 files changed

+192
-4
lines changed

5 files changed

+192
-4
lines changed

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ PG_USER=postgres
44
PG_PASSWORD=postgres
55
PG_DATABASE=stacks_blockchain_api
66
PG_SCHEMA=public
7+
PG_SSL=false
8+
9+
# The connection URI below can be used in place of the PG variables above,
10+
# but if enabled it must be defined without others or omitted.
11+
# PG_CONNECTION_URI=
712

813
# Enable to have stacks-node events streamed to a file while the application is running
914
# STACKS_EXPORT_EVENTS_FILE=/tmp/stacks-events.tsv

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@
187187
"jest": "^26.6.3",
188188
"nodemon": "^2.0.3",
189189
"npm-api": "^1.0.0",
190+
"pg-connection-string": "^2.5.0",
190191
"prettier": "2.2.1",
191192
"redoc-cli": "^0.9.12",
192193
"rimraf": "^3.0.2",

src/datastore/postgres-store.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as pgCopyStreams from 'pg-copy-streams';
88
import * as PgCursor from 'pg-cursor';
99

1010
import {
11+
parseArgBoolean,
1112
parsePort,
1213
APP_DIR,
1314
isTestEnv,
@@ -82,15 +83,51 @@ const MIGRATIONS_DIR = path.join(APP_DIR, 'migrations');
8283

8384
type PgClientConfig = ClientConfig & { schema?: string };
8485
export function getPgClientConfig(): PgClientConfig {
85-
const config: PgClientConfig = {
86+
const pgEnvVars = {
8687
database: process.env['PG_DATABASE'],
8788
user: process.env['PG_USER'],
8889
password: process.env['PG_PASSWORD'],
8990
host: process.env['PG_HOST'],
90-
port: parsePort(process.env['PG_PORT']),
91+
port: process.env['PG_PORT'],
92+
ssl: process.env['PG_SSL'],
9193
schema: process.env['PG_SCHEMA'],
9294
};
93-
return config;
95+
const pgConnectionUri = process.env['PG_CONNECTION_URI'];
96+
const pgConfigEnvVar = Object.entries(pgEnvVars).find(([, v]) => typeof v === 'string')?.[0];
97+
if (pgConfigEnvVar && pgConnectionUri) {
98+
throw new Error(
99+
`Both PG_CONNECTION_URI and ${pgConfigEnvVar} environmental variables are defined. PG_CONNECTION_URI must be defined without others or omitted.`
100+
);
101+
}
102+
if (pgConnectionUri) {
103+
const uri = new URL(pgConnectionUri);
104+
const searchParams = Object.fromEntries(
105+
[...uri.searchParams.entries()].map(([k, v]) => [k.toLowerCase(), v])
106+
);
107+
// Not really standardized
108+
const schema: string | undefined =
109+
searchParams['currentschema'] ??
110+
searchParams['current_schema'] ??
111+
searchParams['searchpath'] ??
112+
searchParams['search_path'] ??
113+
searchParams['schema'];
114+
const config: PgClientConfig = {
115+
connectionString: pgConnectionUri,
116+
schema,
117+
};
118+
return config;
119+
} else {
120+
const config: PgClientConfig = {
121+
database: pgEnvVars.database,
122+
user: pgEnvVars.user,
123+
password: pgEnvVars.password,
124+
host: pgEnvVars.host,
125+
port: parsePort(pgEnvVars.port),
126+
ssl: parseArgBoolean(pgEnvVars.ssl),
127+
schema: pgEnvVars.schema,
128+
};
129+
return config;
130+
}
94131
}
95132

96133
export async function runMigrations(

src/helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,35 @@ export function httpPostRequest(
347347
});
348348
}
349349

350+
/**
351+
* Parses a boolean string using conventions from CLI arguments, URL query params, and environmental variables.
352+
* If the input is defined but empty string then true is returned. If the input is undefined or null than false is returned.
353+
* For example, if the input comes from a CLI arg like `--enable_thing` or URL query param like `?enable_thing`, then
354+
* this function expects to receive a defined but empty string, and returns true.
355+
* Otherwise, it checks or values like `true`, `1`, `on`, `yes` (and the inverses).
356+
* Throws if an unexpected input value is provided.
357+
*/
358+
export function parseArgBoolean(val: string | undefined | null): boolean {
359+
if (typeof val === 'undefined' || val === null) {
360+
return false;
361+
}
362+
switch (val.trim().toLowerCase()) {
363+
case '':
364+
case 'true':
365+
case '1':
366+
case 'on':
367+
case 'yes':
368+
return true;
369+
case 'false':
370+
case '0':
371+
case 'off':
372+
case 'no':
373+
return false;
374+
default:
375+
throw new Error(`Cannot parse boolean from "${val}"`);
376+
}
377+
}
378+
350379
export function parsePort(portVal: number | string | undefined): number | undefined {
351380
if (portVal === undefined) {
352381
return undefined;

src/tests/datastore-tests.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ import {
2020
DbBnsSubdomain,
2121
DbTokenOfferingLocked,
2222
} from '../datastore/common';
23-
import { PgDataStore, cycleMigrations, runMigrations } from '../datastore/postgres-store';
23+
import {
24+
PgDataStore,
25+
cycleMigrations,
26+
runMigrations,
27+
getPgClientConfig,
28+
} from '../datastore/postgres-store';
2429
import { PoolClient } from 'pg';
30+
import * as pgConnectionString from 'pg-connection-string';
2531
import { parseDbEvent } from '../api/controllers/db-controller';
2632
import * as assert from 'assert';
2733
import { I32_MAX } from '../helpers';
@@ -55,6 +61,41 @@ describe('in-memory datastore', () => {
5561
});
5662
});
5763

64+
function testEnvVars(envVars: Record<string, string | undefined>, use: () => void): void;
65+
function testEnvVars(
66+
envVars: Record<string, string | undefined>,
67+
use: () => Promise<void>
68+
): Promise<void>;
69+
function testEnvVars(
70+
envVars: Record<string, string | undefined>,
71+
use: () => void | Promise<void>
72+
): void | Promise<void> {
73+
const existing = Object.fromEntries(
74+
Object.keys(envVars)
75+
.filter(k => k in process.env)
76+
.map(k => [k, process.env[k]])
77+
);
78+
const added = Object.keys(envVars).filter(k => !(k in process.env));
79+
Object.entries(envVars).forEach(([k, v]) => {
80+
process.env[k] = v;
81+
if (v === undefined) {
82+
delete process.env[k];
83+
}
84+
});
85+
const restoreEnvVars = () => {
86+
added.forEach(k => delete process.env[k]);
87+
Object.entries(existing).forEach(([k, v]) => (process.env[k] = v));
88+
};
89+
try {
90+
const runFn = use();
91+
if (runFn instanceof Promise) {
92+
return runFn.finally(() => restoreEnvVars());
93+
}
94+
} finally {
95+
restoreEnvVars();
96+
}
97+
}
98+
5899
describe('postgres datastore', () => {
59100
let db: PgDataStore;
60101
let client: PoolClient;
@@ -66,6 +107,81 @@ describe('postgres datastore', () => {
66107
client = await db.pool.connect();
67108
});
68109

110+
test('postgres uri config', () => {
111+
const uri =
112+
'postgresql://test_user:[email protected]:3211/test_db?ssl=true&currentSchema=test_schema';
113+
testEnvVars(
114+
{
115+
PG_CONNECTION_URI: uri,
116+
PG_DATABASE: undefined,
117+
PG_USER: undefined,
118+
PG_PASSWORD: undefined,
119+
PG_HOST: undefined,
120+
PG_PORT: undefined,
121+
PG_SSL: undefined,
122+
PG_SCHEMA: undefined,
123+
},
124+
() => {
125+
const config = getPgClientConfig();
126+
const parsedUrl = pgConnectionString.parse(uri);
127+
expect(parsedUrl.database).toBe('test_db');
128+
expect(parsedUrl.user).toBe('test_user');
129+
expect(parsedUrl.password).toBe('secret_password');
130+
expect(parsedUrl.host).toBe('database.server.com');
131+
expect(parsedUrl.port).toBe('3211');
132+
expect(parsedUrl.ssl).toBe(true);
133+
expect(config.schema).toBe('test_schema');
134+
}
135+
);
136+
});
137+
138+
test('postgres env var config', () => {
139+
testEnvVars(
140+
{
141+
PG_CONNECTION_URI: undefined,
142+
PG_DATABASE: 'pg_db_db1',
143+
PG_USER: 'pg_user_user1',
144+
PG_PASSWORD: 'pg_password_password1',
145+
PG_HOST: 'pg_host_host1',
146+
PG_PORT: '9876',
147+
PG_SSL: 'true',
148+
PG_SCHEMA: 'pg_schema_schema1',
149+
},
150+
() => {
151+
const config = getPgClientConfig();
152+
expect(config.database).toBe('pg_db_db1');
153+
expect(config.user).toBe('pg_user_user1');
154+
expect(config.password).toBe('pg_password_password1');
155+
expect(config.host).toBe('pg_host_host1');
156+
expect(config.port).toBe(9876);
157+
expect(config.ssl).toBe(true);
158+
expect(config.schema).toBe('pg_schema_schema1');
159+
}
160+
);
161+
});
162+
163+
test('postgres conflicting config', () => {
164+
const uri =
165+
'postgresql://test_user:[email protected]:3211/test_db?ssl=true&currentSchema=test_schema';
166+
testEnvVars(
167+
{
168+
PG_CONNECTION_URI: uri,
169+
PG_DATABASE: 'pg_db_db1',
170+
PG_USER: 'pg_user_user1',
171+
PG_PASSWORD: 'pg_password_password1',
172+
PG_HOST: 'pg_host_host1',
173+
PG_PORT: '9876',
174+
PG_SSL: 'true',
175+
PG_SCHEMA: 'pg_schema_schema1',
176+
},
177+
() => {
178+
expect(() => {
179+
const config = getPgClientConfig();
180+
}).toThrowError();
181+
}
182+
);
183+
});
184+
69185
test('pg address STX balances', async () => {
70186
const dbBlock: DbBlock = {
71187
block_hash: '0x9876',

0 commit comments

Comments
 (0)