feat: writes, procedures, Fastify and Express adapters, documentation

This commit is contained in:
2026-11-02 01:25:49 +01:00
parent 4b276f21e3
commit 6801f257fb
35 changed files with 3109 additions and 182 deletions
+50
View File
@@ -0,0 +1,50 @@
import type { LogicalType } from '../catalog.js';
import { badRequest } from '../errors.js';
import type { Value } from '../plan.js';
import type { Column, Resource } from '../resources.js';
import { typed } from './values.js';
export type Row = Map<string, unknown>;
/** A body value as a value to bind, checked against the column's type the same way a query value is. */
function bodyValue(v: unknown, c: Column): unknown {
if (v === null) return null;
const type: LogicalType = c.type;
if (type === 'json') return JSON.stringify(v);
if (type === 'boolean') {
if (typeof v !== 'boolean') throw badRequest('invalid_value', c.name);
return v;
}
if (type === 'integer' || type === 'number') {
if (typeof v === 'number' && Number.isFinite(v) && (type === 'number' || Number.isInteger(v))) return v;
if (typeof v === 'string') return typed(v, type, c.name);
throw badRequest('invalid_value', c.name);
}
if (typeof v !== 'string') throw badRequest('invalid_value', c.name);
return typed(v, type, c.name) as Value;
}
/**
* The rows of a write body, each a map of writable columns to checked values.
* Every row must name the same columns, so one statement writes them all.
*/
export function bodyRows(resource: Resource, body: unknown, kind: 'create' | 'update', maxRows: number): Row[] {
const list = Array.isArray(body) ? body : [body];
if (list.length === 0 || list.length > maxRows) throw badRequest('bad_body', `between 1 and ${maxRows} rows`);
if (kind === 'update' && Array.isArray(body)) throw badRequest('bad_body', 'an update takes one object');
let keys: string | null = null;
return list.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) throw badRequest('bad_body', 'rows are objects');
const names = Object.keys(item).sort();
if (names.length === 0) throw badRequest('bad_body', 'a row names at least one column');
if (keys !== null && keys !== names.join(',')) throw badRequest('mixed_columns');
keys = names.join(',');
const row: Row = new Map();
for (const name of names) {
const c = resource.columns.get(name);
if (!c || !c[kind]) throw badRequest('not_writable', name);
row.set(name, bodyValue((item as Record<string, unknown>)[name], c));
}
return row;
});
}
+1
View File
@@ -8,5 +8,6 @@ export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, Catalo
export type { Column, Junction, Relation, Resource } from './resources.js';
export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js';
export { SqlPrimitives } from './sql/primitives.js';
export { SqlReader } from './sql/reader.js';
export { SqlRenderer, type Statement } from './sql/renderer.js';
export { RenderContext } from './sql/context.js';
+67
View File
@@ -0,0 +1,67 @@
import type { Catalog, CatalogFunction } from './catalog.js';
import { badRequest } from './errors.js';
import { typed } from './grammar/values.js';
import type { ProcedureOptions } from './options.js';
export interface Procedure {
template: string;
pattern: RegExp;
params: string[];
fn: CatalogFunction;
}
/** Declared procedures, each bound to exactly one function of the catalog. */
export function buildProcedures(catalog: Catalog, declared: Record<string, ProcedureOptions>): Procedure[] {
return Object.entries(declared).map(([template, o]) => {
const [schema, name] = o.function.includes('.') ? o.function.split('.', 2) : ['', o.function];
const matches = catalog.functions.filter((f) => f.name === name && (!schema || f.schema === schema));
if (matches.length !== 1) throw new Error(`restloom: procedure ${template} needs exactly one function named ${o.function} in an exposed schema, found ${matches.length}`);
const params: string[] = [];
const source = template.replace(/^\/+|\/+$/g, '').split('/').map((part) => {
const p = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(part);
if (!p) return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!matches[0].args.some((a) => a.name === p[1])) throw new Error(`restloom: procedure ${template} names {${p[1]}}, which ${o.function} does not take`);
params.push(p[1]);
return '([^/]+)';
}).join('/');
return { template, pattern: new RegExp(`^/${source}/?$`), params, fn: matches[0] };
});
}
export function matchProcedure(list: Procedure[], path: string): { procedure: Procedure; values: Record<string, string> } | null {
for (const procedure of list) {
const m = procedure.pattern.exec(path);
if (!m) continue;
try {
return { procedure, values: Object.fromEntries(procedure.params.map((p, i) => [p, decodeURIComponent(m[i + 1])])) };
} catch {
return null;
}
}
return null;
}
/** The call's arguments from the path and the body, by name, each checked against its type. */
export function procedureArgs(fn: CatalogFunction, fromPath: Record<string, string>, body: unknown): [string, unknown][] {
if (body !== undefined && (body === null || typeof body !== 'object' || Array.isArray(body))) throw badRequest('bad_body', 'arguments are an object');
const given: Record<string, unknown> = { ...(body as Record<string, unknown> | undefined), ...fromPath };
for (const name of Object.keys(given)) if (!fn.args.some((a) => a.name === name)) throw badRequest('unknown_argument', name);
const out: [string, unknown][] = [];
for (const a of fn.args) {
if (!(a.name in given)) {
if (!a.hasDefault) throw badRequest('missing_argument', a.name);
continue;
}
const v = given[a.name];
if (v === null) out.push([a.name, null]);
else if (a.type === 'json') out.push([a.name, JSON.stringify(v)]);
else if (a.type === 'array') {
if (!Array.isArray(v)) throw badRequest('invalid_value', a.name);
out.push([a.name, v]);
} else if (a.type === 'other' && (typeof v === 'string' || typeof v === 'number')) out.push([a.name, v]);
else if (typeof v === 'boolean' || typeof v === 'number') out.push([a.name, typed(String(v), a.type, a.name)]);
else if (typeof v === 'string') out.push([a.name, typed(v, a.type, a.name)]);
else throw badRequest('invalid_value', a.name);
}
return out;
}
+2 -1
View File
@@ -32,8 +32,9 @@ export function keyFilter(resource: Resource, id: string): Filter {
/** GET on a collection, or on one row when id is given. */
export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: RestRequest<unknown>, limits: Limits,
scope: (r: Resource) => Filter[], id?: string): Promise<RestResponse> {
scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise<RestResponse> {
const plan = parseRead(resource, request.query, limits);
plan.where.push(...extra);
if (id !== undefined) {
plan.where.unshift(keyFilter(resource, id));
plan.limit = 1;
+44 -12
View File
@@ -1,9 +1,11 @@
import type { Catalog } from './catalog.js';
import { RestError, notFound } from './errors.js';
import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js';
import { buildProcedures, matchProcedure, procedureArgs, type Procedure } from './procedures.js';
import { readRows } from './read.js';
import { buildResources, type Resource } from './resources.js';
import { scopeFilters } from './scope.js';
import { create, remove, update, type WriteContext } from './write.js';
export interface Rest<C = unknown> {
/** Answers one request. Never throws: every failure is a response. */
@@ -14,6 +16,10 @@ export interface Rest<C = unknown> {
resources(): Promise<ReadonlyMap<string, Resource>>;
}
interface Api { resources: Map<string, Resource>; procedures: Procedure[] }
const ACTIONS = { GET: 'read', POST: 'create', PATCH: 'update', DELETE: 'delete' } as const;
function errorBody(err: RestError): RestResponse {
return { status: err.status, headers: {}, body: { error: err.code, ...(err.detail ? { message: err.detail } : {}) } };
}
@@ -35,23 +41,48 @@ function route(path: string): { resource: string; id?: string } | null {
export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits };
const db = options.db;
let loading: Promise<Map<string, Resource>> | null = null;
let loading: Promise<Api> | null = null;
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] })
.then((catalog: Catalog) => buildResources(catalog, options as RestOptions<unknown>))
.then((catalog: Catalog) => ({
resources: buildResources(catalog, options as RestOptions<unknown>),
procedures: buildProcedures(catalog, options.procedures ?? {}),
}))
.catch((err: unknown) => { loading = null; throw err; }));
async function dispatch(request: RestRequest<C>): Promise<RestResponse> {
const target = route(request.path);
const resource = target && (await load()).get(target.resource);
if (!target || !resource) throw notFound();
if (request.method !== 'GET') throw new RestError(405, 'method_not_allowed');
await options.hooks?.authorize?.(request, { resource: resource.name, action: 'read' });
const scope = scopeFilters(options, request.context);
async function inTransaction<T>(request: RestRequest<C>, readOnly: boolean, fn: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>): Promise<T> {
return db.transaction(async (tx) => {
await options.hooks?.transaction?.(tx, request.context);
return readRows(db, tx, resource, request as RestRequest<unknown>, limits, scope, target.id);
}, { timeoutMs: limits.timeoutMs, readOnly: true });
return fn(tx);
}, { timeoutMs: limits.timeoutMs, readOnly });
}
async function dispatch(request: RestRequest<C>): Promise<RestResponse> {
const api = await load();
const scope = scopeFilters(options, request.context);
const call = matchProcedure(api.procedures, request.path);
if (call) {
if (request.method !== 'POST') throw new RestError(405, 'method_not_allowed');
await options.hooks?.authorize?.(request, { procedure: call.procedure.template, action: 'call' });
const args = procedureArgs(call.procedure.fn, call.values, request.body);
return inTransaction(request, false, async (tx) => {
const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope));
const rows = (await tx.query(stmt.sql, stmt.params)).map((r) => (typeof r.r === 'string' ? JSON.parse(r.r) : r.r));
return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null };
});
}
const target = route(request.path);
const resource = target && api.resources.get(target.resource);
if (!target || !resource) throw notFound();
const action = ACTIONS[request.method as keyof typeof ACTIONS];
if (!action || (action === 'create' && target.id !== undefined)) throw new RestError(405, 'method_not_allowed');
await options.hooks?.authorize?.(request, { resource: resource.name, action });
const generic = request as RestRequest<unknown>;
if (action === 'read') return inTransaction(request, true, (tx) => readRows(db, tx, resource, generic, limits, scope, target.id));
return inTransaction(request, false, (tx) => {
const c: WriteContext = { db, tx, resource, request: generic, limits, scope };
return action === 'create' ? create(c) : action === 'update' ? update(c, target.id) : remove(c, target.id);
});
}
return {
@@ -61,12 +92,13 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
} catch (err) {
if (err instanceof RestError) return errorBody(err);
const mapped = db.mapError(err);
if (mapped?.code === 'reference_missing' && request.method === 'DELETE') return errorBody(new RestError(409, 'still_referenced'));
if (mapped) return errorBody(mapped);
options.hooks?.onError?.(err, request);
return errorBody(new RestError(500, 'internal'));
}
},
reload() { loading = null; },
resources: load,
resources: async () => (await load()).resources,
};
}
+149
View File
@@ -0,0 +1,149 @@
import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js';
import type { Relation, Resource } from '../resources.js';
import { RenderContext } from './context.js';
import { SqlPrimitives } from './primitives.js';
export interface Statement { sql: string; params: unknown[] }
const CMP: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
/**
* Writes read statements from a plan. Relations are correlated subqueries, so
* a statement is one SQL text whatever the depth, and every table it touches
* carries the host's scope filters. Each method can be overridden by a dialect.
*/
export class SqlReader extends SqlPrimitives {
context(scope: (r: Resource) => Filter[]): RenderContext {
return new RenderContext(scope);
}
/** FROM clause and correlation of a relation's target, seen from the parent alias. */
relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } {
const target = this.table(rel.target.schema, rel.target.table);
const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx);
if (!rel.through) {
const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`);
return { from: `${target} ${alias}`, where: [...on, ...scoped] };
}
const j = ctx.alias('j');
const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND ');
const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`);
return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] };
}
/** EXISTS through a chain of relations, with `inner` written against the last alias. */
exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias);
const where = [...src.where, ...(tail ? [tail] : [])];
return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`;
}
comparison(expr: string, f: Extract<Filter, { kind: 'cmp' }>, ctx: RenderContext): string {
const bind = (v: Value) => ctx.params.add(v);
switch (f.op) {
case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`;
case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean);
case 'like': return this.like(expr, bind(f.value as Value), false);
case 'ilike': return this.like(expr, bind(f.value as Value), true);
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;
}
}
filter(alias: string, f: Filter, ctx: RenderContext): string {
if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`;
if (f.kind !== 'cmp') {
const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`;
return f.negate ? `NOT ${body}` : body;
}
if (f.path.relations.length === 0) {
const cmp = this.comparison(this.column(alias, f.path.column), f, ctx);
return f.negate ? `NOT (${cmp})` : cmp;
}
const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx));
return f.negate ? `NOT ${found}` : found;
}
filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] {
return list.map((f) => this.filter(alias, f, ctx));
}
/** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */
pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias);
return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`;
}
orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string {
if (t.key.kind === 'count') {
const rels = t.key.relations;
const counted = (parent: string) => {
const a = ctx.alias();
const src = this.relationSource(rels[rels.length - 1], parent, a, ctx);
return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`;
};
return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted);
}
const path: Path = t.key.path;
return path.relations.length === 0
? this.column(alias, path.column)
: this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column));
}
order(terms: OrderTerm[], alias: string, ctx: RenderContext): string {
return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', ');
}
rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string {
const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]);
for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]);
for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]);
return this.jsonObject(pairs);
}
embed(e: Embed, parent: string, ctx: RenderContext): string {
const alias = ctx.alias();
const src = this.relationSource(e.relation, parent, alias, ctx);
const target = e.relation.target;
const row = this.rowJson(target, e.select, alias, ctx);
const where = ` WHERE ${src.where.join(' AND ')}`;
if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`;
const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false }));
const ordered = this.order(terms, alias, ctx);
const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`;
return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`;
}
where(plan: ReadPlan, alias: string, ctx: RenderContext): string {
const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)];
return parts.length ? ` WHERE ${parts.join(' AND ')}` : '';
}
groupBy(plan: ReadPlan, alias: string): string {
return plan.select.aggregates.length && plan.select.columns.length
? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : '';
}
read(plan: ReadPlan, ctx: RenderContext): Statement {
const alias = ctx.alias();
const row = this.rowJson(plan.resource, plan.select, alias, ctx);
const where = this.where(plan, alias, ctx);
const ordered = this.order(plan.order, alias, ctx);
const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
count(plan: ReadPlan, ctx: RenderContext): Statement {
const alias = ctx.alias();
const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`;
const sql = plan.select.aggregates.length && plan.select.columns.length
? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g`
: plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
}
+37 -136
View File
@@ -1,149 +1,50 @@
import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js';
import type { Relation, Resource } from '../resources.js';
import { RenderContext } from './context.js';
import { SqlPrimitives } from './primitives.js';
import type { CatalogFunction } from '../catalog.js';
import type { Filter } from '../plan.js';
import type { Resource } from '../resources.js';
import type { RenderContext } from './context.js';
import { SqlReader, type Statement } from './reader.js';
export interface Statement { sql: string; params: unknown[] }
const CMP: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
export type { Statement } from './reader.js';
/**
* Writes read statements from a plan. Relations are correlated subqueries, so
* a statement is one SQL text whatever the depth, and every table it touches
* carries the host's scope filters. Each method can be overridden by a dialect.
* Every statement restloom writes: reads from SqlReader, then inserts, updates,
* deletes and function calls. Writes return the key of each written row, which
* the core reads back when the client asks for the rows.
*/
export class SqlRenderer extends SqlPrimitives {
context(scope: (r: Resource) => Filter[]): RenderContext {
return new RenderContext(scope);
export class SqlRenderer extends SqlReader {
returning(resource: Resource): string {
return ` RETURNING ${resource.key.map((k) => this.quote(k)).join(', ')}`;
}
/** FROM clause and correlation of a relation's target, seen from the parent alias. */
relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } {
const target = this.table(rel.target.schema, rel.target.table);
const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx);
if (!rel.through) {
const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`);
return { from: `${target} ${alias}`, where: [...on, ...scoped] };
}
const j = ctx.alias('j');
const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND ');
const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`);
return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] };
}
/** EXISTS through a chain of relations, with `inner` written against the last alias. */
exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias);
const where = [...src.where, ...(tail ? [tail] : [])];
return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`;
}
comparison(expr: string, f: Extract<Filter, { kind: 'cmp' }>, ctx: RenderContext): string {
const bind = (v: Value) => ctx.params.add(v);
switch (f.op) {
case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`;
case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean);
case 'like': return this.like(expr, bind(f.value as Value), false);
case 'ilike': return this.like(expr, bind(f.value as Value), true);
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;
}
}
filter(alias: string, f: Filter, ctx: RenderContext): string {
if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`;
if (f.kind !== 'cmp') {
const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`;
return f.negate ? `NOT ${body}` : body;
}
if (f.path.relations.length === 0) {
const cmp = this.comparison(this.column(alias, f.path.column), f, ctx);
return f.negate ? `NOT (${cmp})` : cmp;
}
const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx));
return f.negate ? `NOT ${found}` : found;
}
filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] {
return list.map((f) => this.filter(alias, f, ctx));
}
/** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */
pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias);
return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`;
}
orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string {
if (t.key.kind === 'count') {
const rels = t.key.relations;
const counted = (parent: string) => {
const a = ctx.alias();
const src = this.relationSource(rels[rels.length - 1], parent, a, ctx);
return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`;
};
return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted);
}
const path: Path = t.key.path;
return path.relations.length === 0
? this.column(alias, path.column)
: this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column));
}
order(terms: OrderTerm[], alias: string, ctx: RenderContext): string {
return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', ');
}
rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string {
const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]);
for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]);
for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]);
return this.jsonObject(pairs);
}
embed(e: Embed, parent: string, ctx: RenderContext): string {
const alias = ctx.alias();
const src = this.relationSource(e.relation, parent, alias, ctx);
const target = e.relation.target;
const row = this.rowJson(target, e.select, alias, ctx);
const where = ` WHERE ${src.where.join(' AND ')}`;
if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`;
const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false }));
const ordered = this.order(terms, alias, ctx);
const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`;
return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`;
}
where(plan: ReadPlan, alias: string, ctx: RenderContext): string {
const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)];
return parts.length ? ` WHERE ${parts.join(' AND ')}` : '';
}
groupBy(plan: ReadPlan, alias: string): string {
return plan.select.aggregates.length && plan.select.columns.length
? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : '';
}
read(plan: ReadPlan, ctx: RenderContext): Statement {
const alias = ctx.alias();
const row = this.rowJson(plan.resource, plan.select, alias, ctx);
const where = this.where(plan, alias, ctx);
const ordered = this.order(plan.order, alias, ctx);
const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`;
insert(resource: Resource, columns: string[], rows: unknown[][], ctx: RenderContext): Statement {
const values = rows.map((row) => `(${row.map((v) => ctx.params.add(v)).join(', ')})`).join(', ');
const sql = `INSERT INTO ${this.table(resource.schema, resource.table)} (${columns.map((c) => this.quote(c)).join(', ')}) VALUES ${values}${this.returning(resource)}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
count(plan: ReadPlan, ctx: RenderContext): Statement {
writeTarget(resource: Resource, alias: string): string {
return `${this.table(resource.schema, resource.table)} AS ${alias}`;
}
update(resource: Resource, set: [string, unknown][], where: Filter[], ctx: RenderContext): Statement {
const alias = ctx.alias();
const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`;
const sql = plan.select.aggregates.length && plan.select.columns.length
? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g`
: plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`;
const assignments = set.map(([c, v]) => `${this.quote(c)} = ${ctx.params.add(v)}`).join(', ');
const conditions = this.filters(resource, alias, where, ctx);
const sql = `UPDATE ${this.writeTarget(resource, alias)} SET ${assignments}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}${this.returning(resource)}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
delete(resource: Resource, where: Filter[], ctx: RenderContext): Statement {
const alias = ctx.alias();
const conditions = this.filters(resource, alias, where, ctx);
const sql = `DELETE FROM ${this.writeTarget(resource, alias)}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}${this.returning(resource)}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
/** A function call by argument name; a set-returning function answers its rows, any other its value. */
call(fn: CatalogFunction, args: [string, unknown][], ctx: RenderContext): Statement {
const call = `${this.table(fn.schema, fn.name)}(${args.map(([n, v]) => `${this.quote(n)} => ${ctx.params.add(v)}`).join(', ')})`;
const sql = fn.returnsSet ? `SELECT to_json(x) AS r FROM ${call} x` : `SELECT to_json(${call}) AS r`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { Dialect, Tx } from './dialect.js';
import { RestError, badRequest, notFound } from './errors.js';
import { bodyRows } from './grammar/body.js';
import { parseRead } from './grammar/read.js';
import type { Limits, RestRequest, RestResponse } from './options.js';
import type { Filter, Value } from './plan.js';
import { keyFilter, readRows } from './read.js';
import type { Resource } from './resources.js';
export interface WriteContext {
db: Dialect;
tx: Tx;
resource: Resource;
request: RestRequest<unknown>;
limits: Limits;
scope: (r: Resource) => Filter[];
}
const wantsRows = (r: RestRequest<unknown>) => /\breturn=representation\b/.test(r.headers.prefer ?? '');
/** The value a scope filter pins a column to, keyed by column. */
function pinned(c: WriteContext): Map<string, Value> {
const out = new Map<string, Value>();
for (const f of c.scope(c.resource)) if (f.kind === 'cmp' && f.op === 'eq') out.set(f.path.column, f.value as Value);
return out;
}
function keysFilter(resource: Resource, keys: Record<string, unknown>[]): Filter {
const one = (k: Record<string, unknown>): Filter => ({ kind: 'and', negate: false, terms: resource.key.map((col): Filter => ({
kind: 'cmp', path: { relations: [], column: col }, op: 'eq', negate: false, value: k[col] as Value,
})) });
return { kind: 'or', negate: false, terms: keys.map(one) };
}
function selectOnly(request: RestRequest<unknown>, limit: number): URLSearchParams {
const query = new URLSearchParams({ limit: String(limit) });
const select = request.query.get('select');
if (select) query.set('select', select);
return query;
}
/** Reads the written rows back with the request's select, embeds included. */
async function readBack(c: WriteContext, keys: Record<string, unknown>[], status: number): Promise<RestResponse> {
if (keys.length === 0) return { status, headers: {}, body: [] };
const query = selectOnly(c.request, Math.min(keys.length, c.limits.maxPage));
const read = await readRows(c.db, c.tx, c.resource, { ...c.request, query }, c.limits, c.scope, undefined, [keysFilter(c.resource, keys)]);
return { status, headers: {}, body: read.body };
}
/** The filters a PATCH or DELETE applies: the id, or the query's filters on a bulk-enabled resource. */
function target(c: WriteContext, id: string | undefined): Filter[] {
if (id !== undefined) return [keyFilter(c.resource, id)];
if (!c.resource.write.bulk) throw new RestError(405, 'method_not_allowed');
const filters = parseRead(c.resource, c.request.query, c.limits).where;
if (filters.length === 0) throw badRequest('filter_required');
return filters;
}
export async function create(c: WriteContext): Promise<RestResponse> {
if (!c.resource.write.create) throw new RestError(405, 'method_not_allowed');
const rows = bodyRows(c.resource, c.request.body, 'create', c.limits.bodyRows);
const scope = pinned(c);
const columns = [...new Set([...rows[0].keys(), ...scope.keys()])];
const values = rows.map((row) => columns.map((col) => {
if (scope.has(col) && row.has(col) && row.get(col) !== scope.get(col)) throw new RestError(403, 'out_of_scope', col);
return scope.has(col) ? scope.get(col) : row.get(col);
}));
const stmt = c.db.renderer.insert(c.resource, columns, values, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
return wantsRows(c.request) ? readBack(c, keys, 201) : { status: 201, headers: {}, body: undefined };
}
export async function update(c: WriteContext, id: string | undefined): Promise<RestResponse> {
if (!c.resource.write.update) throw new RestError(405, 'method_not_allowed');
const [row] = bodyRows(c.resource, c.request.body, 'update', 1);
const scope = pinned(c);
for (const [col, v] of row) if (scope.has(col) && v !== scope.get(col)) throw new RestError(403, 'out_of_scope', col);
const where = [...c.scope(c.resource), ...target(c, id)];
const stmt = c.db.renderer.update(c.resource, [...row], where, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
if (id !== undefined && keys.length === 0) throw notFound();
if (!wantsRows(c.request)) return { status: 204, headers: {}, body: undefined };
const back = await readBack(c, keys, 200);
return id !== undefined ? { ...back, body: (back.body as unknown[])[0] } : back;
}
export async function remove(c: WriteContext, id: string | undefined): Promise<RestResponse> {
if (!c.resource.write.delete) throw new RestError(405, 'method_not_allowed');
const where = [...c.scope(c.resource), ...target(c, id)];
const before = wantsRows(c.request)
? await readRows(c.db, c.tx, c.resource, { ...c.request, query: selectOnly(c.request, c.limits.maxPage) }, c.limits, c.scope, undefined, where)
: null;
const stmt = c.db.renderer.delete(c.resource, where, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
if (id !== undefined && keys.length === 0) throw notFound();
if (!before) return { status: 204, headers: {}, body: undefined };
return { status: 200, headers: {}, body: id !== undefined ? (before.body as unknown[])[0] : before.body };
}
+20
View File
@@ -0,0 +1,20 @@
import { SqlRenderer } from '../dist/index.js';
/** A dialect with one table and canned rows, for adapter and wiring tests without a database. */
export function fakeDialect(rows = [{ id: 1, name: 'one' }]) {
const statements = [];
return {
statements,
name: 'fake',
capabilities: { returning: true, estimatedCount: false, rowSecurity: false },
renderer: new SqlRenderer(),
async introspect() {
const col = (name, type) => ({ name, type, nativeType: type, nullable: false, generated: false });
return { functions: [], tables: [{ schema: 'app', name: 'items', kind: 'table', primaryKey: ['id'], foreignKeys: [], columns: [col('id', 'integer'), col('name', 'text')] }] };
},
async transaction(fn) {
return fn({ async query(sql) { statements.push(sql); return sql.startsWith('SELECT COUNT') ? [{ n: rows.length }] : rows.map((r) => ({ r })); } });
},
mapError: () => null,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createREST, nodeListener } from '../dist/index.js';
import { fakeDialect } from './fake-dialect.mjs';
test('the node listener serves under its prefix and refuses bad bodies', async () => {
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } });
const server = http.createServer(nodeListener(rest, { prefix: '/api' })).listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
assert.equal((await fetch(`${base}/api/items`)).status, 200);
assert.equal((await fetch(`${base}/items`)).status, 404);
const bad = await fetch(`${base}/api/items`, { method: 'POST', body: '{nope' });
assert.equal(bad.status, 400);
assert.deepEqual(await bad.json(), { error: 'bad_body' });
} finally {
server.close();
}
});
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@crudy/restloom-express",
"version": "0.1.0",
"type": "module",
"description": "Express adapter for restloom",
"keywords": [
"restloom",
"express",
"rest",
"api"
],
"author": "Marc Villain",
"homepage": "https://restloom.crudy.fr",
"repository": {
"type": "git",
"url": "git+https://github.com/crudylabs/restloom.git",
"directory": "packages/express"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p .",
"prepack": "npm run build"
},
"dependencies": {
"@crudy/restloom": "^0.1.0"
},
"peerDependencies": {
"express": "^5.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0",
"express": "^5.0.0",
"@types/express": "^5.0.0"
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Rest } from '@crudy/restloom';
import type { NextFunction, Request, Response } from 'express';
export interface RestloomExpressOptions<C> {
/** Builds the context hooks receive, e.g. the authenticated user. */
context?(req: Request): C | Promise<C>;
}
/**
* An Express handler answering every path it is mounted on with restloom.
* Mount it after a JSON body parser: `app.use('/api', express.json(), restloom(rest))`.
*/
export function restloom<C>(rest: Rest<C>, options: RestloomExpressOptions<C> = {}) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const url = new URL(req.url, 'http://localhost');
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
const out = await rest.handle({
method: req.method,
path: url.pathname || '/',
query: url.searchParams,
headers,
body: req.body,
context: (await options.context?.(req)) as C,
});
res.status(out.status).set(out.headers);
if (out.body === undefined) res.end();
else res.json(out.body);
} catch (err) {
next(err);
}
};
}
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { createREST } from '@crudy/restloom';
import { restloom } from '../dist/index.js';
import { fakeDialect } from '../../core/test/fake-dialect.mjs';
const require = createRequire(import.meta.url);
const express = require('express');
test('serves restloom where it is mounted', async () => {
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } });
const app = express();
app.use('/api', express.json(), restloom(rest));
const server = app.listen(0);
const port = server.address().port;
try {
const res = await fetch(`http://127.0.0.1:${port}/api/items?select=id`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), [{ id: 1, name: 'one' }]);
assert.equal(res.headers.get('content-range'), '0-0/*');
const write = await fetch(`http://127.0.0.1:${port}/api/items`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"name":"x"}' });
assert.equal(write.status, 405);
} finally {
server.close();
}
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@crudy/restloom-fastify",
"version": "0.1.0",
"type": "module",
"description": "Fastify adapter for restloom",
"keywords": [
"restloom",
"fastify",
"rest",
"api"
],
"author": "Marc Villain",
"homepage": "https://restloom.crudy.fr",
"repository": {
"type": "git",
"url": "git+https://github.com/crudylabs/restloom.git",
"directory": "packages/fastify"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p .",
"prepack": "npm run build"
},
"dependencies": {
"@crudy/restloom": "^0.1.0"
},
"peerDependencies": {
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0",
"fastify": "^5.0.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { Rest } from '@crudy/restloom';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export interface RestloomFastifyOptions<C> {
/** Builds the context hooks receive, e.g. the authenticated user. */
context?(req: FastifyRequest): C | Promise<C>;
}
/**
* A Fastify plugin answering every path under its prefix with restloom.
* Register it with a prefix: `app.register(restloom(rest), { prefix: '/api' })`.
*/
export function restloom<C>(rest: Rest<C>, options: RestloomFastifyOptions<C> = {}) {
return async function plugin(app: FastifyInstance): Promise<void> {
const handler = async (req: FastifyRequest, reply: FastifyReply) => {
const url = new URL(req.raw.url ?? '/', 'http://localhost');
const prefix = app.prefix ?? '';
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
const res = await rest.handle({
method: req.method,
path: url.pathname.slice(prefix.length) || '/',
query: url.searchParams,
headers,
body: req.body,
context: (await options.context?.(req)) as C,
});
reply.code(res.status).headers(res.headers);
return res.body === undefined ? reply.send() : reply.send(res.body);
};
app.route({ method: ['GET', 'POST', 'PATCH', 'DELETE'], url: '/*', handler });
};
}
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { createREST } from '@crudy/restloom';
import { restloom } from '../dist/index.js';
import { fakeDialect } from '../../core/test/fake-dialect.mjs';
const require = createRequire(import.meta.url);
const Fastify = require('fastify');
test('serves restloom under its prefix with the request context', async () => {
let seen;
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] }, hooks: { authorize(req) { seen = req.context; } } });
const app = Fastify();
await app.register(restloom(rest, { context: (req) => ({ user: req.headers['x-user'] }) }), { prefix: '/api' });
const res = await app.inject({ method: 'GET', url: '/api/items?select=id&count=exact', headers: { 'x-user': 'ada' } });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json(), [{ id: 1, name: 'one' }]);
assert.equal(res.headers['content-range'], '0-0/1');
assert.deepEqual(seen, { user: 'ada' });
assert.equal((await app.inject({ method: 'GET', url: '/api/nope' })).statusCode, 404);
await app.close();
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}