feat: one install and one import per database; server dialects open their pool from a URL; nodeListener answers a throwing context; 0.5.0
This commit is contained in:
@@ -32,19 +32,19 @@ Your backend stays in charge:
|
||||
| `@crudy/restloom-express` | Express handler |
|
||||
|
||||
```sh
|
||||
npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||
npm install @crudy/restloom-postgres
|
||||
```
|
||||
|
||||
A database package brings the core and its driver, and exports the whole core.
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
import http from 'node:http';
|
||||
import pg from 'pg';
|
||||
import { createREST, nodeListener } from '@crudy/restloom';
|
||||
import { postgres } from '@crudy/restloom-postgres';
|
||||
import { createREST, nodeListener, postgres } from '@crudy/restloom-postgres';
|
||||
|
||||
const rest = createREST({
|
||||
db: postgres(new pg.Pool()),
|
||||
db: postgres(process.env.DATABASE_URL),
|
||||
expose: { schemas: ['app'] },
|
||||
resources: { todos: { write: true } },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
|
||||
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { RestError } from './errors.js';
|
||||
import type { RestResponse } from './options.js';
|
||||
import type { Rest } from './rest.js';
|
||||
|
||||
export interface NodeListenerOptions<C> {
|
||||
/** Builds the context hooks receive, e.g. the authenticated user. */
|
||||
/** Builds the context hooks receive, e.g. the authenticated user. Throw a RestError to refuse the request. */
|
||||
context?(req: IncomingMessage): C | Promise<C>;
|
||||
/** Path the API is mounted under, e.g. /api. */
|
||||
prefix?: string;
|
||||
@@ -47,6 +48,13 @@ export function nodeListener<C>(rest: Rest<C>, options: NodeListenerOptions<C> =
|
||||
const status = (err as { status?: number }).status ?? 400;
|
||||
return send(res, { status, headers: {}, body: { error: status === 413 ? 'body_too_large' : 'bad_body' } });
|
||||
}
|
||||
let context: C;
|
||||
try {
|
||||
context = (await options.context?.(req)) as C;
|
||||
} catch (err) {
|
||||
const refusal = err instanceof RestError ? err : new RestError(500, 'internal');
|
||||
return send(res, { status: refusal.status, headers: {}, body: { error: refusal.code, ...(refusal.detail ? { message: refusal.detail } : {}) } });
|
||||
}
|
||||
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
|
||||
send(res, await rest.handle({
|
||||
method: req.method ?? 'GET',
|
||||
@@ -54,7 +62,7 @@ export function nodeListener<C>(rest: Rest<C>, options: NodeListenerOptions<C> =
|
||||
query: url.searchParams,
|
||||
headers,
|
||||
body,
|
||||
context: (await options.context?.(req)) as C,
|
||||
context,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { createREST, nodeListener } from '../dist/index.js';
|
||||
import { createREST, nodeListener, RestError } from '../dist/index.js';
|
||||
import { fakeDialect } from './fake-dialect.mjs';
|
||||
|
||||
test('the node listener serves under its prefix and refuses bad bodies', async () => {
|
||||
@@ -24,3 +24,26 @@ test('Prefer: count=exact counts as count=exact does', async () => {
|
||||
const res = await rest.handle({ method: 'GET', path: '/items', query: new URLSearchParams(), headers: { prefer: 'count=exact' }, context: undefined });
|
||||
assert.equal(res.headers['content-range'], '0-0/1');
|
||||
});
|
||||
|
||||
test('a context that throws answers the request instead of stopping the process', async () => {
|
||||
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } });
|
||||
const server = http.createServer(nodeListener(rest, {
|
||||
context(req) {
|
||||
if (req.headers.authorization === 'bad') throw new RestError(401, 'unauthenticated');
|
||||
if (req.headers.authorization === 'broken') throw new Error('database down');
|
||||
return {};
|
||||
},
|
||||
})).listen(0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
const refused = await fetch(`${base}/items`, { headers: { authorization: 'bad' } });
|
||||
assert.equal(refused.status, 401);
|
||||
assert.deepEqual(await refused.json(), { error: 'unauthenticated' });
|
||||
const broken = await fetch(`${base}/items`, { headers: { authorization: 'broken' } });
|
||||
assert.equal(broken.status, 500);
|
||||
assert.deepEqual(await broken.json(), { error: 'internal' });
|
||||
assert.equal((await fetch(`${base}/items`)).status, 200);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-express",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "Express adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
"@crudy/restloom": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-fastify",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "Fastify adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
"@crudy/restloom": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastify": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mssql",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "SQL Server dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -37,18 +37,12 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mssql": "^12.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mssql": { "optional": true }
|
||||
"@crudy/restloom": "^0.5.0",
|
||||
"mssql": "^12.7.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/mssql": "^12.0.0",
|
||||
"mssql": "12.7.10",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom';
|
||||
import mssqlDriver, { type ConnectionPool } from 'mssql';
|
||||
import mssqlDriver, { type ConnectionPool, type config as MssqlConfig } from 'mssql';
|
||||
import { introspect } from './introspect.js';
|
||||
import { MssqlRenderer } from './renderer.js';
|
||||
|
||||
export * from '@crudy/restloom';
|
||||
|
||||
/** A connected mssql ConnectionPool, or what opens one: a connection string or config. */
|
||||
export type MssqlConnection = ConnectionPool | MssqlConfig | string;
|
||||
|
||||
function poolOf(c: MssqlConnection): Promise<ConnectionPool> {
|
||||
if (typeof c !== 'string' && typeof (c as ConnectionPool).request === 'function') return Promise.resolve(c as ConnectionPool);
|
||||
const ready = new mssqlDriver.ConnectionPool(c as MssqlConfig).connect();
|
||||
// A failed connection surfaces on the first request, not as an unhandled rejection at startup.
|
||||
ready.catch(() => undefined);
|
||||
return ready;
|
||||
}
|
||||
|
||||
export { MssqlRenderer } from './renderer.js';
|
||||
|
||||
const CODES: Record<number, [number, string]> = {
|
||||
@@ -37,20 +50,21 @@ export interface MssqlOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQL Server 2022 dialect over an mssql ConnectionPool. Each request is one
|
||||
* The SQL Server 2022 dialect over an mssql ConnectionPool, given or opened. Each request is one
|
||||
* transaction; the statement timeout is the pool's `requestTimeout`, since the
|
||||
* driver sets it per pool. Row security is SQL Server's security policies,
|
||||
* which a `transaction` hook feeds through sp_set_session_context.
|
||||
*/
|
||||
export function mssql(pool: ConnectionPool, options: MssqlOptions = {}): Dialect {
|
||||
export function mssql(connection: MssqlConnection, options: MssqlOptions = {}): Dialect {
|
||||
const ready = poolOf(connection);
|
||||
return {
|
||||
name: 'mssql',
|
||||
capabilities: { returning: true, estimatedCount: false, rowSecurity: true, procedures: true },
|
||||
renderer: options.renderer ?? new MssqlRenderer(),
|
||||
decode: (value) => (typeof value === 'string' ? JSON.parse(value) : value),
|
||||
introspect: (scope) => introspect(pool, scope),
|
||||
introspect: async (scope) => introspect(await ready, scope),
|
||||
async transaction(fn) {
|
||||
const tx = new mssqlDriver.Transaction(pool);
|
||||
const tx = new mssqlDriver.Transaction(await ready);
|
||||
await tx.begin();
|
||||
try {
|
||||
const out = await fn({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mysql",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "MySQL dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,17 +36,11 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mysql2": "^3.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mysql2": { "optional": true }
|
||||
"@crudy/restloom": "^0.5.0",
|
||||
"mysql2": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"mysql2": "^3.24.5",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom';
|
||||
import type { Pool } from 'mysql2/promise';
|
||||
import { createPool, type Pool, type PoolOptions } from 'mysql2/promise';
|
||||
import { introspect } from './introspect.js';
|
||||
import { MysqlRenderer } from './renderer.js';
|
||||
|
||||
export * from '@crudy/restloom';
|
||||
|
||||
/** A mysql2/promise Pool, or what opens one: a connection URL or pool options. */
|
||||
export type MysqlConnection = Pool | PoolOptions | string;
|
||||
|
||||
const poolOf = (c: MysqlConnection): Pool =>
|
||||
typeof c === 'string' ? createPool({ uri: c }) : typeof (c as Pool).getConnection === 'function' ? (c as Pool) : createPool(c as PoolOptions);
|
||||
|
||||
export { MysqlRenderer } from './renderer.js';
|
||||
|
||||
const CODES: Record<number, [number, string]> = {
|
||||
@@ -31,12 +39,13 @@ export interface MysqlOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The MySQL 8 dialect over a mysql2/promise Pool. Each request is one
|
||||
* The MySQL 8 dialect over a mysql2/promise Pool, given or opened. Each request is one
|
||||
* transaction on one connection, in UTC, with MAX_EXECUTION_TIME for reads.
|
||||
* Text compares with each column's collation, so with a case-insensitive
|
||||
* collation `eq` ignores case.
|
||||
*/
|
||||
export function mysql(pool: Pool, options: MysqlOptions = {}): Dialect {
|
||||
export function mysql(connection: MysqlConnection, options: MysqlOptions = {}): Dialect {
|
||||
const pool = poolOf(connection);
|
||||
return {
|
||||
name: 'mysql',
|
||||
capabilities: { returning: false, estimatedCount: false, rowSecurity: false, procedures: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-postgres",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "Postgres dialect for restloom",
|
||||
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
|
||||
@@ -18,18 +18,12 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg": { "optional": true }
|
||||
"@crudy/restloom": "^0.5.0",
|
||||
"pg": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"pg": "^8.16.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { SqlRenderer, type Dialect, type Tx } from '@crudy/restloom';
|
||||
import type { Pool } from 'pg';
|
||||
import pg, { type Pool, type PoolConfig } from 'pg';
|
||||
import { mapError } from './errors.js';
|
||||
import { introspect } from './introspect.js';
|
||||
|
||||
export * from '@crudy/restloom';
|
||||
export { mapError } from './errors.js';
|
||||
|
||||
/** A pg Pool, or what opens one: a connection string or a pool config. */
|
||||
export type PostgresConnection = Pool | PoolConfig | string;
|
||||
|
||||
const poolOf = (c: PostgresConnection): Pool =>
|
||||
typeof c === 'string' ? new pg.Pool({ connectionString: c }) : typeof (c as Pool).connect === 'function' ? (c as Pool) : new pg.Pool(c as PoolConfig);
|
||||
|
||||
/** Postgres writes every primitive the core's defaults assume; subclass it to change one. */
|
||||
export class PostgresRenderer extends SqlRenderer {}
|
||||
|
||||
@@ -13,11 +20,13 @@ export interface PostgresOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Postgres dialect over a `pg` Pool. Every request runs in one transaction
|
||||
* The Postgres dialect over a `pg` Pool, given or opened from a connection
|
||||
* string or config. Every request runs in one transaction
|
||||
* with a local statement timeout, read-only for reads, so a `transaction` hook
|
||||
* can set the role and session settings that row security reads.
|
||||
*/
|
||||
export function postgres(pool: Pool, options: PostgresOptions = {}): Dialect {
|
||||
export function postgres(connection: PostgresConnection, options: PostgresOptions = {}): Dialect {
|
||||
const pool = poolOf(connection);
|
||||
return {
|
||||
name: 'postgres',
|
||||
capabilities: { returning: true, estimatedCount: true, rowSecurity: true, procedures: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-sqlite",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "SQLite dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.4.0"
|
||||
"@crudy/restloom": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"better-sqlite3": ">=12"
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Database } from 'better-sqlite3';
|
||||
import { introspect } from './introspect.js';
|
||||
import { SqliteRenderer } from './renderer.js';
|
||||
|
||||
export * from '@crudy/restloom';
|
||||
|
||||
export { SqliteRenderer } from './renderer.js';
|
||||
|
||||
const CODES: Record<string, [number, string]> = {
|
||||
@@ -27,7 +29,8 @@ export interface SqliteOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQLite dialect over a better-sqlite3 Database. It turns on foreign keys
|
||||
* The SQLite dialect over a better-sqlite3 Database, or any object with its
|
||||
* API, such as sql.js in a browser. It turns on foreign keys
|
||||
* and case-sensitive LIKE for the connection. One connection runs one
|
||||
* transaction at a time, so requests are queued. SQLite cannot stop a running
|
||||
* statement, so `limits.timeoutMs` does not apply, and it has no functions to
|
||||
|
||||
Reference in New Issue
Block a user