feat: writes, procedures, Fastify and Express adapters, documentation
This commit is contained in:
@@ -8,4 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener.
|
- `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener.
|
||||||
|
- Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`.
|
||||||
|
- Procedures: database functions at declared paths, arguments checked by name and type.
|
||||||
- `@crudy/restloom-postgres`: the Postgres dialect.
|
- `@crudy/restloom-postgres`: the Postgres dialect.
|
||||||
|
- `@crudy/restloom-fastify` and `@crudy/restloom-express`: framework adapters.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Your backend stays in charge:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener |
|
| `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener |
|
||||||
| `@crudy/restloom-postgres` | PostgreSQL |
|
| `@crudy/restloom-postgres` | PostgreSQL |
|
||||||
|
| `@crudy/restloom-fastify` | Fastify plugin |
|
||||||
|
| `@crudy/restloom-express` | Express handler |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install @crudy/restloom @crudy/restloom-postgres pg
|
npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||||
@@ -49,7 +51,17 @@ http.createServer(nodeListener(rest)).listen(3000);
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Query grammar](docs/query-grammar.md): select, filters, relations, order, pages, counts, aggregates.
|
1. [Getting started](docs/getting-started.md)
|
||||||
|
2. [Discovery and overrides](docs/discovery.md): what becomes a resource, hidden columns, relation names.
|
||||||
|
3. [Query grammar](docs/query-grammar.md): select, filters, relations, order, pages, counts, aggregates.
|
||||||
|
4. [Writes and procedures](docs/writes.md)
|
||||||
|
5. [Security and hooks](docs/security.md)
|
||||||
|
6. [Adapters](docs/adapters.md): Node, Fastify, Express, your own routes.
|
||||||
|
7. [Dialects](docs/dialects.md) and [writing a dialect](docs/writing-a-dialect.md)
|
||||||
|
8. [Errors](docs/errors.md)
|
||||||
|
9. [Compared with PostgREST](docs/postgrest.md)
|
||||||
|
|
||||||
|
The examples in the grammar and writes pages are the conformance suite: each one runs against every database and must answer exactly what the page shows.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -1,33 +1,49 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CASE = /```http\n([\s\S]*?)```[ \t]*\n+(?:Answer: (\d{3})[ \t]*\n+)?(?:```json\n([\s\S]*?)```)?/g;
|
||||||
|
|
||||||
|
function request(block) {
|
||||||
|
const [head, ...rest] = block.split(/\n\s*\n/);
|
||||||
|
const lines = head.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||||
|
const headers = {};
|
||||||
|
let line = '';
|
||||||
|
for (const l of lines) {
|
||||||
|
const h = /^([A-Za-z-]+):\s*(.*)$/.exec(l);
|
||||||
|
if (h && line) headers[h[1].toLowerCase()] = h[2];
|
||||||
|
else line += l;
|
||||||
|
}
|
||||||
|
const body = rest.join('\n\n').trim();
|
||||||
|
return { line, headers, body: body ? JSON.parse(body) : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every ```http block followed by a ```json block in a docs page is one
|
* The conformance cases of a docs page: every ```http block (request line,
|
||||||
* conformance case: the request, then the exact body it must answer. A body
|
* header lines, a blank line and a JSON body) followed by an optional
|
||||||
* with an `error` key must come with a 4xx or 5xx status.
|
* `Answer: <status>` line and an optional ```json block with the exact body.
|
||||||
|
* Without an Answer line the status is 2xx, or 4xx when the body has `error`.
|
||||||
*/
|
*/
|
||||||
export function examples(file) {
|
export function examples(file) {
|
||||||
const text = readFileSync(file, 'utf8');
|
const text = readFileSync(file, 'utf8');
|
||||||
const out = [];
|
const out = [];
|
||||||
const re = /```http\n([\s\S]*?)```\s*\n+```json\n([\s\S]*?)```/g;
|
for (let m; (m = CASE.exec(text));) {
|
||||||
for (let m; (m = re.exec(text));) {
|
if (!m[2] && m[3] === undefined) continue;
|
||||||
const line = text.slice(0, m.index).split('\n').length;
|
const { line, headers, body } = request(m[1]);
|
||||||
const request = m[1].split('\n').map((l) => l.trim()).filter(Boolean).join('');
|
const space = line.indexOf(' ');
|
||||||
const space = request.indexOf(' ');
|
const target = new URL(line.slice(space + 1), 'http://example');
|
||||||
const target = new URL(request.slice(space + 1), 'http://example');
|
|
||||||
out.push({
|
out.push({
|
||||||
name: `${file.split('/').pop()}:${line} ${request}`,
|
name: `${file.split('/').pop()}:${text.slice(0, m.index).split('\n').length} ${line}`,
|
||||||
method: request.slice(0, space),
|
method: line.slice(0, space), path: target.pathname, query: target.searchParams, headers, body,
|
||||||
path: target.pathname,
|
status: m[2] ? Number(m[2]) : undefined,
|
||||||
query: target.searchParams,
|
expected: m[3] === undefined ? undefined : JSON.parse(m[3]),
|
||||||
expected: JSON.parse(m[2]),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(rest, example, assert) {
|
export async function run(rest, example, assert) {
|
||||||
const res = await rest.handle({ method: example.method, path: example.path, query: example.query, headers: {}, context: undefined });
|
const res = await rest.handle({ method: example.method, path: example.path, query: example.query, headers: example.headers, body: example.body, context: undefined });
|
||||||
assert.deepEqual(res.body, example.expected, `status ${res.status}`);
|
assert.deepEqual(res.body, example.expected, `status ${res.status}`);
|
||||||
if (example.expected && typeof example.expected === 'object' && 'error' in example.expected) assert.ok(res.status >= 400, `status ${res.status}`);
|
if (example.status) assert.equal(res.status, example.status);
|
||||||
|
else if (example.expected && typeof example.expected === 'object' && 'error' in example.expected) assert.ok(res.status >= 400, `status ${res.status}`);
|
||||||
else assert.ok(res.status < 300, `status ${res.status}`);
|
else assert.ok(res.status < 300, `status ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,11 @@ INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
|
|||||||
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
||||||
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
||||||
(3, 'Brute force', 1, 4, '2026-03-05');
|
(3, 'Brute force', 1, 4, '2026-03-05');
|
||||||
|
|
||||||
|
CREATE FUNCTION demo.retire_asset(asset integer, reason text DEFAULT NULL) RETURNS integer
|
||||||
|
LANGUAGE sql AS $$ UPDATE demo.assets SET status = 'retired' WHERE id = asset RETURNING id $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT a.* FROM demo.assets a JOIN demo.asset_tags t ON t.asset_id = a.id JOIN demo.tags g ON g.id = t.tag_id
|
||||||
|
WHERE g.name = tag ORDER BY a.id $$;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** The docs pages whose examples are conformance cases, and the options every database runs them with. */
|
||||||
|
export const PAGES = ['query-grammar.md', 'writes.md'];
|
||||||
|
|
||||||
|
export const OPTIONS = {
|
||||||
|
expose: { schemas: ['demo'] },
|
||||||
|
resources: {
|
||||||
|
asset_tags: { expose: false },
|
||||||
|
tags: { write: true },
|
||||||
|
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
|
||||||
|
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
'assets/{asset}/retire': { function: 'demo.retire_asset' },
|
||||||
|
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
import { test, before, after } from 'node:test';
|
import { describe, test, before, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { createREST } from '../packages/core/dist/index.js';
|
import { createREST } from '../packages/core/dist/index.js';
|
||||||
import { postgres } from '../packages/postgres/dist/index.js';
|
import { postgres } from '../packages/postgres/dist/index.js';
|
||||||
import { examples, run } from './examples.mjs';
|
import { examples, run } from './examples.mjs';
|
||||||
|
import { PAGES, OPTIONS } from './pages.mjs';
|
||||||
|
|
||||||
const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url));
|
const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url));
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const url = process.env.RESTLOOM_PG_URL;
|
const url = process.env.RESTLOOM_PG_URL;
|
||||||
const docs = new URL('../docs/', import.meta.url).pathname;
|
const fixture = readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8');
|
||||||
const pages = ['query-grammar.md'];
|
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
let rest;
|
before(() => { if (url) pool = new Pool({ connectionString: url }); });
|
||||||
before(async () => {
|
|
||||||
if (!url) return;
|
|
||||||
pool = new Pool({ connectionString: url });
|
|
||||||
await pool.query(readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8'));
|
|
||||||
rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, resources: { asset_tags: { expose: false } } });
|
|
||||||
});
|
|
||||||
after(async () => { await pool?.end(); });
|
after(async () => { await pool?.end(); });
|
||||||
|
|
||||||
for (const page of pages) {
|
for (const page of PAGES) {
|
||||||
for (const example of examples(docs + page)) {
|
describe(`postgres ${page}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
|
||||||
test(`postgres ${example.name}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => run(rest, example, assert));
|
let rest;
|
||||||
|
before(async () => {
|
||||||
|
await pool.query(fixture);
|
||||||
|
rest = createREST({ db: postgres(pool), ...OPTIONS });
|
||||||
|
});
|
||||||
|
for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) {
|
||||||
|
test(example.name, () => run(rest, example, assert));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Adapters
|
||||||
|
|
||||||
|
The core answers a plain request object, so it fits any framework:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const response = await rest.handle({ method, path, query, headers, body, context });
|
||||||
|
// { status, headers, body }
|
||||||
|
```
|
||||||
|
|
||||||
|
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/assets/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
|
||||||
|
|
||||||
|
## Node
|
||||||
|
|
||||||
|
`nodeListener` from the core serves `node:http` with no other dependency. It reads JSON bodies up to 1 MiB (`maxBodyBytes`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
http.createServer(nodeListener(rest, { prefix: '/api', context: (req) => userFrom(req) })).listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fastify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @crudy/restloom-fastify
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { restloom } from '@crudy/restloom-fastify';
|
||||||
|
|
||||||
|
await app.register(restloom(rest, { context: (req) => req.user }), { prefix: '/api' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Fastify's own hooks, rate limits and logging run as for any route.
|
||||||
|
|
||||||
|
## Express
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @crudy/restloom-express
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { restloom } from '@crudy/restloom-express';
|
||||||
|
|
||||||
|
app.use('/api', express.json(), restloom(rest, { context: (req) => req.user }));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Your own routes
|
||||||
|
|
||||||
|
A host that registers every route itself, to attach its own permissions or documentation to each, calls `rest.handle` from those routes. That is how crudy mounts restloom: each declared route checks its permission, then hands the request to restloom.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Dialects
|
||||||
|
|
||||||
|
A dialect is the package that knows one database: how to read its catalog, how to write its SQL, how to run a transaction and what its errors mean.
|
||||||
|
|
||||||
|
| Package | Database | Embeds | Writes return rows | Estimated count | Row security |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes |
|
||||||
|
|
||||||
|
## Postgres
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { postgres } from '@crudy/restloom-postgres';
|
||||||
|
|
||||||
|
const db = postgres(pool);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Transactions:** every request is one transaction, `READ ONLY` for reads, with `SET LOCAL statement_timeout` from `limits.timeoutMs`.
|
||||||
|
- **Types:** enums read as text. A domain reads as its base type.
|
||||||
|
- **Timestamps:** they are written in the session's time zone, as Postgres writes them in JSON.
|
||||||
|
- **Procedures:** calls use named arguments, so the order in the body does not matter.
|
||||||
|
|
||||||
|
## Changing how SQL is written
|
||||||
|
|
||||||
|
Every piece of SQL comes from one overridable method of the dialect's renderer. To change one, subclass it and pass it in:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { PostgresRenderer, postgres } from '@crudy/restloom-postgres';
|
||||||
|
|
||||||
|
class Renderer extends PostgresRenderer {
|
||||||
|
like(expr: string, pattern: string, insensitive: boolean) {
|
||||||
|
return insensitive ? `unaccent(${expr}) ILIKE unaccent(${pattern})` : super.like(expr, pattern, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = postgres(pool, { renderer: new Renderer() });
|
||||||
|
```
|
||||||
|
|
||||||
|
The pieces are listed in [writing a dialect](./writing-a-dialect.md).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Discovery and overrides
|
||||||
|
|
||||||
|
restloom reads the database's catalog once, on the first request, and builds the API from it. `rest.reload()` reads it again on the next request, after a migration for example.
|
||||||
|
|
||||||
|
## What becomes a resource
|
||||||
|
|
||||||
|
`expose` names what is served. Nothing else is.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expose: { schemas: ['app'] } // every table and view of app
|
||||||
|
expose: { tables: ['app.assets', 'app.users'] } // only these
|
||||||
|
```
|
||||||
|
|
||||||
|
A resource is named after its table. When two exposed schemas hold a table of the same name, both are named `schema_table`. `resources.<name>.expose: false` removes one, for example a junction table you only want used as a relation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resources: { asset_tags: { expose: false } }
|
||||||
|
```
|
||||||
|
|
||||||
|
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { asset_rows: { key: ['asset_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
|
||||||
|
|
||||||
|
## Columns
|
||||||
|
|
||||||
|
Every column is visible except:
|
||||||
|
|
||||||
|
- **Sensitive names:** columns whose name contains one of these words: `password`, `passwd`, `pwd`, `hash`, `hashed`, `salt`, `secret`, `token`, `api_key`, `private_key`, `otp`, `totp`, `mfa`, `recovery`. A word is a whole part of the name between underscores: `password_hash` and `reset_token` are hidden, `tokenizer` is not.
|
||||||
|
- **Columns you hide:** `resources: { users: { hide: ['phone'] } }`.
|
||||||
|
|
||||||
|
`show` makes a sensitive-looking column visible on purpose: `resources: { files: { show: ['sha256_hash'] } }`. `sensitive` replaces the whole list with your own pattern.
|
||||||
|
|
||||||
|
A hidden column cannot be read, filtered, sorted or written, and naming it answers exactly as naming a column that does not exist. It can still join a relation.
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
Relations come from foreign keys between exposed resources:
|
||||||
|
|
||||||
|
| In the catalog | Gives |
|
||||||
|
|---|---|
|
||||||
|
| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many |
|
||||||
|
| `asset_tags` has a primary key made of a key to `assets` and a key to `tags` | `assets.tags` and `tags.assets`, many-to-many |
|
||||||
|
|
||||||
|
Names:
|
||||||
|
|
||||||
|
- **To-one:** the foreign key column without `_id` (`owner_id` gives `owner`). Otherwise the target table's name.
|
||||||
|
- **To-many:** the source table's name.
|
||||||
|
- **Collisions:** when a name is taken by a column or another relation, the next candidate is used. For to-one, that is the target table's name, then `<column>_<table>`. For to-many, it is `<table>_by_<column>`, and for many-to-many `<table>_via_<junction>`.
|
||||||
|
|
||||||
|
A relation to a table that is not exposed does not exist. A foreign key to a hidden table exposes nothing.
|
||||||
|
|
||||||
|
`relations` overrides, adds or removes them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resources: {
|
||||||
|
users: {
|
||||||
|
relations: {
|
||||||
|
users: false, // remove the discovered one
|
||||||
|
reports: { many: 'users', via: 'manager_id' }, // to-many: users whose manager_id is my key
|
||||||
|
team: { one: 'teams', via: 'team_id' }, // to-one, e.g. for a view without foreign keys
|
||||||
|
badges: { many: 'badges', through: 'user_badges' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An override with the name of a column is refused when the catalog is read, and so is any option naming a resource, column or table that does not exist. Call `await rest.resources()` at start so a typo stops your server from booting instead of failing its first request.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Errors
|
||||||
|
|
||||||
|
Every error is a JSON object with a stable code, and a message naming the part of the request at fault when there is one:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "unknown_column", "message": "password_hash" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A message never contains SQL or database text.
|
||||||
|
|
||||||
|
## The request
|
||||||
|
|
||||||
|
| Code | Status | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `not_found` | 404 | no such resource, or no such row, or a row you may not see |
|
||||||
|
| `method_not_allowed` | 405 | the resource does not allow this method, or a procedure was not called with POST |
|
||||||
|
| `unknown_column` | 400 | the column does not exist or is hidden |
|
||||||
|
| `unknown_relation` | 400 | the relation does not exist |
|
||||||
|
| `unknown_operator` | 400 | the operator is not one of `eq neq gt gte lt lte in like ilike is` |
|
||||||
|
| `bad_operator` | 400 | the operator does not apply to the column's type |
|
||||||
|
| `invalid_value` | 400 | the value does not fit the column's type |
|
||||||
|
| `bad_filter` | 400 | a filter is not `operator.value` |
|
||||||
|
| `bad_logic` | 400 | an `or` or `and` is not `(term,term)` |
|
||||||
|
| `bad_list` | 400 | an `in` list is malformed, empty, or too long |
|
||||||
|
| `bad_name` | 400 | a dotted or quoted name is malformed |
|
||||||
|
| `bad_syntax` | 400 | unbalanced quotes or parentheses |
|
||||||
|
| `bad_select` | 400 | a select item is malformed |
|
||||||
|
| `duplicate_select` | 400 | a name is selected twice |
|
||||||
|
| `unknown_aggregate` | 400 | not one of `count sum avg min max` |
|
||||||
|
| `bad_aggregate` | 400 | the aggregate does not apply to the column's type |
|
||||||
|
| `aggregate_with_embed` | 400 | a grouped read cannot embed relations |
|
||||||
|
| `aggregate_in_embed` | 400 | an embed cannot hold aggregates |
|
||||||
|
| `bad_order` | 400 | an order term names a to-many column, an unorderable type, or, in a grouped read, an unselected column |
|
||||||
|
| `not_embedded` | 400 | `relation.order`, `.limit` or `.offset` names a relation that is not embedded |
|
||||||
|
| `too_deep` | 400 | a path or an embed walks more relations than `limits.depth` |
|
||||||
|
| `too_many_filters` | 400 | more filters than `limits.filters` |
|
||||||
|
| `bad_limit`, `bad_offset` | 400 | not a whole number |
|
||||||
|
| `limit_exceeded` | 400 | a limit over `limits.maxPage` |
|
||||||
|
| `bad_count` | 400 | `count` is not `exact` or `estimated` |
|
||||||
|
|
||||||
|
## Writes and procedures
|
||||||
|
|
||||||
|
| Code | Status | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `bad_body` | 400 | the body is not an object or a list of objects within `limits.bodyRows` |
|
||||||
|
| `body_too_large` | 413 | the body is over the adapter's size limit |
|
||||||
|
| `mixed_columns` | 400 | the rows of a body name different columns |
|
||||||
|
| `not_writable` | 400 | the column cannot be written by this method |
|
||||||
|
| `filter_required` | 400 | a bulk update or delete has no filter |
|
||||||
|
| `out_of_scope` | 403 | a value contradicts the host's scope |
|
||||||
|
| `unknown_argument` | 400 | the procedure's function takes no argument of that name |
|
||||||
|
| `missing_argument` | 400 | an argument without a default is missing |
|
||||||
|
|
||||||
|
## The database
|
||||||
|
|
||||||
|
| Code | Status | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `duplicate` | 409 | a unique constraint refused the row |
|
||||||
|
| `reference_missing` | 409 | a foreign key points to a row that does not exist |
|
||||||
|
| `still_referenced` | 409 | the row is still referenced by another |
|
||||||
|
| `value_required` | 400 | a required column has no value |
|
||||||
|
| `value_rejected` | 400 | a check constraint refused the value |
|
||||||
|
| `forbidden` | 403 | the database refused the session's role |
|
||||||
|
| `timeout` | 503 | the statement ran past `limits.timeoutMs` |
|
||||||
|
| `internal` | 500 | anything else; your `onError` hook receives it |
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Getting started
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the core, the package for your database, and its driver:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||||
|
```
|
||||||
|
|
||||||
|
| Database | Package | Driver |
|
||||||
|
|---|---|---|
|
||||||
|
| PostgreSQL 13+ | `@crudy/restloom-postgres` | `pg` |
|
||||||
|
|
||||||
|
## Serve a schema
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import http from 'node:http';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { createREST, nodeListener } from '@crudy/restloom';
|
||||||
|
import { postgres } from '@crudy/restloom-postgres';
|
||||||
|
|
||||||
|
const rest = createREST({
|
||||||
|
db: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
|
||||||
|
expose: { schemas: ['app'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
Every table and view of `app` is now a resource, read-only:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named.
|
||||||
|
- [Query grammar](./query-grammar.md): everything a GET can ask.
|
||||||
|
- [Writes and procedures](./writes.md): turning writes on, calling functions.
|
||||||
|
- [Security and hooks](./security.md): authorising requests, row security, scoping.
|
||||||
|
- [Adapters](./adapters.md): Fastify, Express, or any framework.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Compared with PostgREST
|
||||||
|
|
||||||
|
restloom keeps what makes PostgREST pleasant, the catalog as the source and a query string anyone can type, and changes where control sits.
|
||||||
|
|
||||||
|
| | PostgREST | restloom |
|
||||||
|
|---|---|---|
|
||||||
|
| Runs as | a separate server | a library inside your backend |
|
||||||
|
| Databases | PostgreSQL | PostgreSQL; more through dialects |
|
||||||
|
| What is served | everything the role can reach in the schema | what you name in `expose`, minus hidden columns |
|
||||||
|
| Sensitive columns | served unless the role cannot read them | hidden by name unless shown |
|
||||||
|
| Writes | everything the role may write | off until a resource enables them, by column |
|
||||||
|
| Authorisation | database roles and row security | your hooks, plus row security where the database has it |
|
||||||
|
| Relations | inferred from foreign keys | inferred from foreign keys, overridable, removable |
|
||||||
|
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.pci`: at least one; `not.`: none |
|
||||||
|
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` |
|
||||||
|
| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` |
|
||||||
|
| Over-sized limit | capped | refused with `limit_exceeded` |
|
||||||
|
| Errors | database messages | stable codes, no database text |
|
||||||
|
|
||||||
|
The grammar is close enough that most PostgREST reads work unchanged: `select`, `eq`, `in`, `is`, `like`, `order`, `limit`, `offset`, `or`, embeds, and `Prefer: return=representation`.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Security and hooks
|
||||||
|
|
||||||
|
restloom decides what can be asked. Your backend decides who may ask it, through hooks.
|
||||||
|
|
||||||
|
## What restloom enforces
|
||||||
|
|
||||||
|
- **Exposure:** only the schemas and tables you name are served, and only their visible columns.
|
||||||
|
- **Bound values:** every value is a bound parameter, checked against its column's type before any SQL runs. Names come from the catalog and are quoted; nothing from a request is written into SQL.
|
||||||
|
- **Bounded requests:** every request is limited:
|
||||||
|
|
||||||
|
| Limit | Default | Option |
|
||||||
|
|---|---|---|
|
||||||
|
| Rows per page | 50, at most 1000 | `limits.page`, `limits.maxPage` |
|
||||||
|
| Relations walked by a path or an embed | 2 | `limits.depth` |
|
||||||
|
| Filters, every or/and term included | 50 | `limits.filters` |
|
||||||
|
| Items in one `in` list | 500 | `limits.inList` |
|
||||||
|
| Rows in one write body | 1000 | `limits.bodyRows` |
|
||||||
|
| Statement time | 5 s | `limits.timeoutMs` |
|
||||||
|
|
||||||
|
A request over a limit is refused, never quietly cut down.
|
||||||
|
|
||||||
|
- **Errors:** they answer stable codes. A database message never reaches the client. Unexpected errors answer `internal` and reach your `onError` hook.
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createREST({
|
||||||
|
db,
|
||||||
|
expose: { schemas: ['app'] },
|
||||||
|
hooks: {
|
||||||
|
authorize(request, target) { /* throw a RestError to refuse */ },
|
||||||
|
async transaction(tx, context) { /* session settings, role */ },
|
||||||
|
scope(resource, context) { return { tenant_id: context.tenantId }; },
|
||||||
|
onError(error, request) { log.error(error); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Hook | Runs | Use it to |
|
||||||
|
|---|---|---|
|
||||||
|
| `authorize(request, target)` | before any SQL, with `{ resource, action }` or `{ procedure, action: 'call' }` | refuse a request: `throw new RestError(403, 'forbidden')` |
|
||||||
|
| `transaction(tx, context)` | first, in every transaction | set the role and session settings row security reads |
|
||||||
|
| `scope(resource, context)` | for every resource a query touches, embeds and relation filters included | add equality filters, e.g. a tenant column, on databases without row security |
|
||||||
|
| `onError(error, request)` | for every answer of `internal` | log it |
|
||||||
|
|
||||||
|
`context` is what your adapter builds from the request, usually the authenticated user. restloom never reads it.
|
||||||
|
|
||||||
|
## Row security
|
||||||
|
|
||||||
|
With Postgres, row security is the strongest perimeter: the database itself hides the rows a session may not see. Connect as a role without `BYPASSRLS`, and set what your policies read in the `transaction` hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async transaction(tx, context) {
|
||||||
|
await tx.query("SELECT set_config('app.user_id', $1, true)", [context.userId]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every read and write of the request then runs under those policies, embeds and relation filters included.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
On a database without row security, `scope` keeps a request inside its perimeter. It returns column values a row must have, and restloom applies them:
|
||||||
|
|
||||||
|
- **Reads:** every query on the resource, and every relation reaching it, only matches rows with those values.
|
||||||
|
- **Inserts:** the scoped columns are filled in. A body giving another value is refused with `out_of_scope`.
|
||||||
|
- **Updates and deletes:** only rows with those values are reached. An update moving a row out of scope is refused.
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# Writes and procedures
|
||||||
|
|
||||||
|
Writes are off until a resource enables them. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resources: {
|
||||||
|
tags: { write: true },
|
||||||
|
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
|
||||||
|
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
'assets/{asset}/retire': { function: 'demo.retire_asset' },
|
||||||
|
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update.
|
||||||
|
|
||||||
|
| Method | Path | Does | Answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/tags` | creates one row, or an array of rows | 201 |
|
||||||
|
| PATCH | `/tags/4` | updates one row | 204, or 200 with the row |
|
||||||
|
| DELETE | `/tags/4` | deletes one row | 204, or 200 with the row |
|
||||||
|
| PATCH, DELETE | `/incidents?severity=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
|
||||||
|
|
||||||
|
## Getting the rows back
|
||||||
|
|
||||||
|
Without a `Prefer` header a write answers with no body. `Prefer: return=representation` answers the written rows, read back with the request's `select`, so embeds work too.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /tags?select=id,name
|
||||||
|
Prefer: return=representation
|
||||||
|
|
||||||
|
{ "id": 4, "name": "critical" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 201
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 4, "name": "critical" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /tags
|
||||||
|
|
||||||
|
[{ "id": 5, "name": "legacy" }, { "id": 6, "name": "edge" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 201
|
||||||
|
|
||||||
|
Every row of a body names the same columns, so one statement writes them all.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /tags
|
||||||
|
|
||||||
|
[{ "id": 7, "name": "a" }, { "name": "b" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "mixed_columns" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A column outside the resource's writable list is refused, whatever its value:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /assets
|
||||||
|
|
||||||
|
{ "name": "new laptop", "status": "open", "active": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "not_writable", "message": "active" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating and deleting
|
||||||
|
|
||||||
|
PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404.
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /assets/2?select=id,name,owner(name)
|
||||||
|
Prefer: return=representation
|
||||||
|
|
||||||
|
{ "owner_id": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 200
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /tags/4
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 204
|
||||||
|
|
||||||
|
A row still referenced by another is not deleted:
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /tags/3
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "still_referenced" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /assets/1
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "method_not_allowed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A resource with `bulk: true` also takes PATCH and DELETE on the collection, with at least one filter. Without a filter they are refused, so no request can empty a table by accident.
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /incidents?severity=lt.3&select=id,title
|
||||||
|
Prefer: return=representation
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 200
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 2, "title": "Spoofed mail" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /incidents
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "filter_required" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
Body values are checked against each column's type, as query values are: a number for a number column, `true` or `false` for a boolean, a string in ISO 8601 for a date or a time. The database's own constraints answer with stable codes:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /tags
|
||||||
|
|
||||||
|
{ "id": 1, "name": "duplicate key" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "duplicate" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /incidents
|
||||||
|
|
||||||
|
{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "reference_missing" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Procedures
|
||||||
|
|
||||||
|
A procedure calls a database function at a path you choose. A `{name}` in the path passes that segment as the argument of the same name; the body's keys pass the others. Arguments are checked by name and type, and one with a default may be left out. A procedure is always a POST.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /assets/3/retire
|
||||||
|
|
||||||
|
{ "reason": "end of life" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
3
|
||||||
|
```
|
||||||
|
|
||||||
|
A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /tags/gdpr/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 2, "name": "mail relay", "status": "open", "owner_id": 1, "cost": 300, "active": true, "created_at": "2026-01-05T09:30:00+00:00" },
|
||||||
|
{ "id": 4, "name": "payroll db", "status": "open", "owner_id": 3, "cost": 4000, "active": true, "created_at": "2026-02-11T16:45:00+00:00" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /assets/3/retire
|
||||||
|
|
||||||
|
{ "why": "no reason" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "unknown_argument", "message": "why" }
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Writing a dialect
|
||||||
|
|
||||||
|
A dialect implements one interface:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Dialect {
|
||||||
|
name: string;
|
||||||
|
capabilities: { returning: boolean; estimatedCount: boolean; rowSecurity: boolean };
|
||||||
|
renderer: SqlRenderer;
|
||||||
|
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>;
|
||||||
|
transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>;
|
||||||
|
mapError(err: unknown): RestError | null;
|
||||||
|
estimate?(tx: Tx, sql: string, params: unknown[]): Promise<number | null>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Does |
|
||||||
|
|---|---|
|
||||||
|
| `introspect` | reads tables, views, columns, primary keys, foreign keys and functions, and maps each type to a logical one: `text`, `integer`, `number`, `boolean`, `uuid`, `date`, `timestamp`, `time`, `json`, `binary`, `array` or `other` |
|
||||||
|
| `transaction` | runs a request's statements in one transaction with a statement timeout |
|
||||||
|
| `mapError` | turns an error the client caused into a stable code; anything else returns `null` and answers `internal` |
|
||||||
|
| `renderer` | writes the SQL |
|
||||||
|
|
||||||
|
The core decides everything else from the logical types: which operators a column accepts, which values are valid, which columns can be summed.
|
||||||
|
|
||||||
|
## The renderer
|
||||||
|
|
||||||
|
`SqlRenderer` writes Postgres SQL. A dialect subclasses it and overrides what its database writes differently. Statements are built only from these pieces, so an override reaches every statement that uses it:
|
||||||
|
|
||||||
|
| Method | Postgres writes |
|
||||||
|
|---|---|
|
||||||
|
| `quote(name)` | `"name"` |
|
||||||
|
| `placeholder(n)` | `$n` |
|
||||||
|
| `jsonObject(pairs)` | `json_build_object('a', x, ...)` |
|
||||||
|
| `jsonArray(row, order)` | `COALESCE(json_agg(row ORDER BY order), '[]'::json)` |
|
||||||
|
| `jsonValue(expr, column)` | the column, unchanged |
|
||||||
|
| `nested(expr)` | a nested JSON subquery, unchanged |
|
||||||
|
| `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` |
|
||||||
|
| `isBoolean(expr, value)` | `IS TRUE`, `IS FALSE` |
|
||||||
|
| `orderBy(expr, desc, nulls)` | `ASC`/`DESC NULLS FIRST`/`LAST` |
|
||||||
|
| `page(limit, offset)` | `LIMIT n OFFSET m` |
|
||||||
|
| `first(select)` | `... LIMIT 1` |
|
||||||
|
| `rowNumber(order)` | `ROW_NUMBER() OVER (ORDER BY ...)` |
|
||||||
|
| `returning(resource)` | `RETURNING key` |
|
||||||
|
| `writeTarget(resource, alias)` | `table AS alias` |
|
||||||
|
| `call(fn, args)` | `SELECT to_json(fn(arg => $1))` |
|
||||||
|
|
||||||
|
Larger units can be overridden too: `relationSource`, `exists`, `filter`, `embed`, `read`, `count`, `insert`, `update`, `delete`.
|
||||||
|
|
||||||
|
Placeholders are written last: fragments carry internal tokens, and the final text gets the database's own placeholders in text order. A dialect with positional placeholders (`?`) gets its values in the right order whatever order the fragments were built in.
|
||||||
|
|
||||||
|
## Proving it
|
||||||
|
|
||||||
|
A dialect passes when the conformance suite does. Add a fixture with the demo schema and data in `conformance/fixtures/<dialect>.sql`, and a runner next to `postgres.test.mjs`. Every example of the docs pages then runs against your database and must answer exactly what the page shows.
|
||||||
Generated
+1712
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/core",
|
"packages/core",
|
||||||
"packages/postgres"
|
"packages/postgres",
|
||||||
|
"packages/fastify",
|
||||||
|
"packages/express"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@ export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, Catalo
|
|||||||
export type { Column, Junction, Relation, Resource } from './resources.js';
|
export type { Column, Junction, Relation, Resource } from './resources.js';
|
||||||
export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js';
|
export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js';
|
||||||
export { SqlPrimitives } from './sql/primitives.js';
|
export { SqlPrimitives } from './sql/primitives.js';
|
||||||
|
export { SqlReader } from './sql/reader.js';
|
||||||
export { SqlRenderer, type Statement } from './sql/renderer.js';
|
export { SqlRenderer, type Statement } from './sql/renderer.js';
|
||||||
export { RenderContext } from './sql/context.js';
|
export { RenderContext } from './sql/context.js';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -32,8 +32,9 @@ export function keyFilter(resource: Resource, id: string): Filter {
|
|||||||
|
|
||||||
/** GET on a collection, or on one row when id is given. */
|
/** 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,
|
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);
|
const plan = parseRead(resource, request.query, limits);
|
||||||
|
plan.where.push(...extra);
|
||||||
if (id !== undefined) {
|
if (id !== undefined) {
|
||||||
plan.where.unshift(keyFilter(resource, id));
|
plan.where.unshift(keyFilter(resource, id));
|
||||||
plan.limit = 1;
|
plan.limit = 1;
|
||||||
|
|||||||
+44
-12
@@ -1,9 +1,11 @@
|
|||||||
import type { Catalog } from './catalog.js';
|
import type { Catalog } from './catalog.js';
|
||||||
import { RestError, notFound } from './errors.js';
|
import { RestError, notFound } from './errors.js';
|
||||||
import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.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 { readRows } from './read.js';
|
||||||
import { buildResources, type Resource } from './resources.js';
|
import { buildResources, type Resource } from './resources.js';
|
||||||
import { scopeFilters } from './scope.js';
|
import { scopeFilters } from './scope.js';
|
||||||
|
import { create, remove, update, type WriteContext } from './write.js';
|
||||||
|
|
||||||
export interface Rest<C = unknown> {
|
export interface Rest<C = unknown> {
|
||||||
/** Answers one request. Never throws: every failure is a response. */
|
/** Answers one request. Never throws: every failure is a response. */
|
||||||
@@ -14,6 +16,10 @@ export interface Rest<C = unknown> {
|
|||||||
resources(): Promise<ReadonlyMap<string, Resource>>;
|
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 {
|
function errorBody(err: RestError): RestResponse {
|
||||||
return { status: err.status, headers: {}, body: { error: err.code, ...(err.detail ? { message: err.detail } : {}) } };
|
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> {
|
export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
|
||||||
const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits };
|
const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits };
|
||||||
const db = options.db;
|
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 ?? [] })
|
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; }));
|
.catch((err: unknown) => { loading = null; throw err; }));
|
||||||
|
|
||||||
async function dispatch(request: RestRequest<C>): Promise<RestResponse> {
|
async function inTransaction<T>(request: RestRequest<C>, readOnly: boolean, fn: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>): Promise<T> {
|
||||||
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);
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
await options.hooks?.transaction?.(tx, request.context);
|
await options.hooks?.transaction?.(tx, request.context);
|
||||||
return readRows(db, tx, resource, request as RestRequest<unknown>, limits, scope, target.id);
|
return fn(tx);
|
||||||
}, { timeoutMs: limits.timeoutMs, readOnly: true });
|
}, { 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 {
|
return {
|
||||||
@@ -61,12 +92,13 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof RestError) return errorBody(err);
|
if (err instanceof RestError) return errorBody(err);
|
||||||
const mapped = db.mapError(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);
|
if (mapped) return errorBody(mapped);
|
||||||
options.hooks?.onError?.(err, request);
|
options.hooks?.onError?.(err, request);
|
||||||
return errorBody(new RestError(500, 'internal'));
|
return errorBody(new RestError(500, 'internal'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reload() { loading = null; },
|
reload() { loading = null; },
|
||||||
resources: load,
|
resources: async () => (await load()).resources,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +1,50 @@
|
|||||||
import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js';
|
import type { CatalogFunction } from '../catalog.js';
|
||||||
import type { Relation, Resource } from '../resources.js';
|
import type { Filter } from '../plan.js';
|
||||||
import { RenderContext } from './context.js';
|
import type { Resource } from '../resources.js';
|
||||||
import { SqlPrimitives } from './primitives.js';
|
import type { RenderContext } from './context.js';
|
||||||
|
import { SqlReader, type Statement } from './reader.js';
|
||||||
|
|
||||||
export interface Statement { sql: string; params: unknown[] }
|
export type { Statement } from './reader.js';
|
||||||
|
|
||||||
const CMP: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes read statements from a plan. Relations are correlated subqueries, so
|
* Every statement restloom writes: reads from SqlReader, then inserts, updates,
|
||||||
* a statement is one SQL text whatever the depth, and every table it touches
|
* deletes and function calls. Writes return the key of each written row, which
|
||||||
* carries the host's scope filters. Each method can be overridden by a dialect.
|
* the core reads back when the client asks for the rows.
|
||||||
*/
|
*/
|
||||||
export class SqlRenderer extends SqlPrimitives {
|
export class SqlRenderer extends SqlReader {
|
||||||
context(scope: (r: Resource) => Filter[]): RenderContext {
|
returning(resource: Resource): string {
|
||||||
return new RenderContext(scope);
|
return ` RETURNING ${resource.key.map((k) => this.quote(k)).join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FROM clause and correlation of a relation's target, seen from the parent alias. */
|
insert(resource: Resource, columns: string[], rows: unknown[][], ctx: RenderContext): Statement {
|
||||||
relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } {
|
const values = rows.map((row) => `(${row.map((v) => ctx.params.add(v)).join(', ')})`).join(', ');
|
||||||
const target = this.table(rel.target.schema, rel.target.table);
|
const sql = `INSERT INTO ${this.table(resource.schema, resource.table)} (${columns.map((c) => this.quote(c)).join(', ')}) VALUES ${values}${this.returning(resource)}`;
|
||||||
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));
|
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 alias = ctx.alias();
|
||||||
const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`;
|
const assignments = set.map(([c, v]) => `${this.quote(c)} = ${ctx.params.add(v)}`).join(', ');
|
||||||
const sql = plan.select.aggregates.length && plan.select.columns.length
|
const conditions = this.filters(resource, alias, where, ctx);
|
||||||
? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g`
|
const sql = `UPDATE ${this.writeTarget(resource, alias)} SET ${assignments}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}${this.returning(resource)}`;
|
||||||
: plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`;
|
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));
|
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user