@worker-tools/deno-kv-storage
Version:
An implementation of the StorageArea (1,2,3) interface for Deno with an extensible system for supporting various database backends.
367 lines (336 loc) • 9.82 kB
text/typescript
import { parseConnectionUri } from "../utils/utils.js";
import { ConnectionParamsError } from "../client/error.js";
import { fromFileUrl, isAbsolute } from "../deps.js";
/**
* The connection string must match the following URI structure. All parameters but database and user are optional
*
* `postgres://user:password@hostname:port/database?sslmode=mode...`
*
* You can additionally provide the following url search parameters
*
* - application_name
* - dbname
* - host
* - password
* - port
* - sslmode
* - user
*/
export type ConnectionString = string;
/**
* This function retrieves the connection options from the environmental variables
* as they are, without any extra parsing
*
* It will throw if no env permission was provided on startup
*/
function getPgEnv(): ClientOptions {
return {
database: Deno.env.get("PGDATABASE"),
hostname: Deno.env.get("PGHOST"),
port: Deno.env.get("PGPORT"),
user: Deno.env.get("PGUSER"),
password: Deno.env.get("PGPASSWORD"),
applicationName: Deno.env.get("PGAPPNAME"),
};
}
export interface ConnectionOptions {
/**
* By default, any client will only attempt to stablish
* connection with your database once. Setting this parameter
* will cause the client to attempt reconnection as many times
* as requested before erroring
*
* default: `1`
*/
attempts: number;
}
type TLSModes = "disable" | "prefer" | "require";
// TODO
// Refactor enabled and enforce into one single option for 1.0
export interface TLSOptions {
/**
* If TLS support is enabled or not. If the server requires TLS,
* the connection will fail.
*
* Default: `true`
*/
enabled: boolean;
/**
* This will force the connection to run over TLS
* If the server doesn't support TLS, the connection will fail
*
* Default: `false`
*/
enforce: boolean;
/**
* A list of root certificates that will be used in addition to the default
* root certificates to verify the server's certificate.
*
* Must be in PEM format.
*
* Default: `[]`
*/
caCertificates: string[];
}
export interface ClientOptions {
applicationName?: string;
connection?: Partial<ConnectionOptions>;
database?: string;
hostname?: string;
host_type?: "tcp" | "socket";
password?: string;
port?: string | number;
tls?: Partial<TLSOptions>;
user?: string;
}
export interface ClientConfiguration {
applicationName: string;
connection: ConnectionOptions;
database: string;
hostname: string;
host_type: "tcp" | "socket";
password?: string;
port: number;
tls: TLSOptions;
user: string;
}
function formatMissingParams(missingParams: string[]) {
return `Missing connection parameters: ${
missingParams.join(
", ",
)
}`;
}
/**
* This validates the options passed are defined and have a value other than null
* or empty string, it throws a connection error otherwise
*
* @param has_env_access This parameter will change the error message if set to true,
* telling the user to pass env permissions in order to read environmental variables
*/
function assertRequiredOptions(
options: Partial<ClientConfiguration>,
requiredKeys: (keyof ClientOptions)[],
has_env_access: boolean,
): asserts options is ClientConfiguration {
const missingParams: (keyof ClientOptions)[] = [];
for (const key of requiredKeys) {
if (
options[key] === "" ||
options[key] === null ||
options[key] === undefined
) {
missingParams.push(key);
}
}
if (missingParams.length) {
let missing_params_message = formatMissingParams(missingParams);
if (!has_env_access) {
missing_params_message +=
"\nConnection parameters can be read from environment variables only if Deno is run with env permission";
}
throw new ConnectionParamsError(missing_params_message);
}
}
// TODO
// Support more options from the spec
/** options from URI per https://www.postgresql.org/docs/14/libpq-connect.html#LIBPQ-CONNSTRING */
interface PostgresUri {
application_name?: string;
dbname?: string;
driver: string;
host?: string;
password?: string;
port?: string;
sslmode?: TLSModes;
user?: string;
}
function parseOptionsFromUri(connString: string): ClientOptions {
let postgres_uri: PostgresUri;
try {
const uri = parseConnectionUri(connString);
postgres_uri = {
application_name: uri.params.application_name,
dbname: uri.path || uri.params.dbname,
driver: uri.driver,
host: uri.host || uri.params.host,
password: uri.password || uri.params.password,
port: uri.port || uri.params.port,
// Compatibility with JDBC, not standard
// Treat as sslmode=require
sslmode: uri.params.ssl === "true"
? "require"
: uri.params.sslmode as TLSModes,
user: uri.user || uri.params.user,
};
} catch (e) {
// TODO
// Use error cause
throw new ConnectionParamsError(
`Could not parse the connection string due to ${e}`,
);
}
if (!["postgres", "postgresql"].includes(postgres_uri.driver)) {
throw new ConnectionParamsError(
`Supplied DSN has invalid driver: ${postgres_uri.driver}.`,
);
}
// No host by default means socket connection
const host_type = postgres_uri.host
? (isAbsolute(postgres_uri.host) ? "socket" : "tcp")
: "socket";
let tls: TLSOptions | undefined;
switch (postgres_uri.sslmode) {
case undefined: {
break;
}
case "disable": {
tls = { enabled: false, enforce: false, caCertificates: [] };
break;
}
case "prefer": {
tls = { enabled: true, enforce: false, caCertificates: [] };
break;
}
case "require": {
tls = { enabled: true, enforce: true, caCertificates: [] };
break;
}
default: {
throw new ConnectionParamsError(
`Supplied DSN has invalid sslmode '${postgres_uri.sslmode}'. Only 'disable', 'require', and 'prefer' are supported`,
);
}
}
return {
applicationName: postgres_uri.application_name,
database: postgres_uri.dbname,
hostname: postgres_uri.host,
host_type,
password: postgres_uri.password,
port: postgres_uri.port,
tls,
user: postgres_uri.user,
};
}
const DEFAULT_OPTIONS:
& Omit<ClientConfiguration, "database" | "user" | "hostname">
& { host: string; socket: string } = {
applicationName: "deno_postgres",
connection: {
attempts: 1,
},
host: "127.0.0.1",
socket: "/tmp",
host_type: "socket",
port: 5432,
tls: {
enabled: true,
enforce: false,
caCertificates: [],
},
};
export function createParams(
params: string | ClientOptions = {},
): ClientConfiguration {
if (typeof params === "string") {
params = parseOptionsFromUri(params);
}
let pgEnv: ClientOptions = {};
let has_env_access = true;
try {
pgEnv = getPgEnv();
} catch (e) {
if (e instanceof Deno.errors.PermissionDenied) {
has_env_access = false;
} else {
throw e;
}
}
const provided_host = params.hostname ?? pgEnv.hostname;
// If a host is provided, the default connection type is TCP
const host_type = params.host_type ??
(provided_host ? "tcp" : DEFAULT_OPTIONS.host_type);
if (!["tcp", "socket"].includes(host_type)) {
throw new ConnectionParamsError(`"${host_type}" is not a valid host type`);
}
let host: string;
if (host_type === "socket") {
const socket = provided_host ?? DEFAULT_OPTIONS.socket;
try {
if (!isAbsolute(socket)) {
const parsed_host = new URL(socket, Deno.mainModule);
// Resolve relative path
if (parsed_host.protocol === "file:") {
host = fromFileUrl(parsed_host);
} else {
throw new ConnectionParamsError(
"The provided host is not a file path",
);
}
} else {
host = socket;
}
} catch (e) {
// TODO
// Add error cause
throw new ConnectionParamsError(
`Could not parse host "${socket}" due to "${e}"`,
);
}
} else {
host = provided_host ?? DEFAULT_OPTIONS.host;
}
let port: number;
if (params.port) {
port = Number(params.port);
} else if (pgEnv.port) {
port = Number(pgEnv.port);
} else {
port = DEFAULT_OPTIONS.port;
}
if (Number.isNaN(port) || port === 0) {
throw new ConnectionParamsError(
`"${params.port ?? pgEnv.port}" is not a valid port number`,
);
}
if (host_type === "socket" && params?.tls) {
throw new ConnectionParamsError(
`No TLS options are allowed when host type is set to "socket"`,
);
}
const tls_enabled = !!(params?.tls?.enabled ?? DEFAULT_OPTIONS.tls.enabled);
const tls_enforced = !!(params?.tls?.enforce ?? DEFAULT_OPTIONS.tls.enforce);
if (!tls_enabled && tls_enforced) {
throw new ConnectionParamsError(
"Can't enforce TLS when client has TLS encryption is disabled",
);
}
// TODO
// Perhaps username should be taken from the PC user as a default?
const connection_options = {
applicationName: params.applicationName ?? pgEnv.applicationName ??
DEFAULT_OPTIONS.applicationName,
connection: {
attempts: params?.connection?.attempts ??
DEFAULT_OPTIONS.connection.attempts,
},
database: params.database ?? pgEnv.database,
hostname: host,
host_type,
password: params.password ?? pgEnv.password,
port,
tls: {
enabled: tls_enabled,
enforce: tls_enforced,
caCertificates: params?.tls?.caCertificates ?? [],
},
user: params.user ?? pgEnv.user,
};
assertRequiredOptions(
connection_options,
["applicationName", "database", "hostname", "host_type", "port", "user"],
has_env_access,
);
return connection_options;
}