docs: a to-do app as the example schema in the docs, fixtures and tests

This commit is contained in:
2026-11-02 22:19:20 +01:00
parent 93427d8cb6
commit 8fc42bc33b
18 changed files with 215 additions and 215 deletions
+4 -4
View File
@@ -8,13 +8,13 @@
A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one: A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one:
``` ```
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
``` ```
```json ```json
[ [
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] }, { "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] } { "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] }
] ]
``` ```
@@ -51,7 +51,7 @@ import { postgres } from '@crudy/restloom-postgres';
const rest = createREST({ const rest = createREST({
db: postgres(new pg.Pool()), db: postgres(new pg.Pool()),
expose: { schemas: ['app'] }, expose: { schemas: ['app'] },
resources: { assets: { write: true } }, resources: { todos: { write: true } },
}); });
http.createServer(nodeListener(rest)).listen(3000); http.createServer(nodeListener(rest)).listen(3000);
+28 -28
View File
@@ -1,8 +1,8 @@
IF OBJECT_ID('demo.assets_tagged') IS NOT NULL DROP FUNCTION demo.assets_tagged; IF OBJECT_ID('demo.todos_tagged') IS NOT NULL DROP FUNCTION demo.todos_tagged;
IF OBJECT_ID('demo.incidents') IS NOT NULL DROP TABLE demo.incidents; IF OBJECT_ID('demo.steps') IS NOT NULL DROP TABLE demo.steps;
IF OBJECT_ID('demo.asset_tags') IS NOT NULL DROP TABLE demo.asset_tags; IF OBJECT_ID('demo.todo_tags') IS NOT NULL DROP TABLE demo.todo_tags;
IF OBJECT_ID('demo.tags') IS NOT NULL DROP TABLE demo.tags; IF OBJECT_ID('demo.tags') IS NOT NULL DROP TABLE demo.tags;
IF OBJECT_ID('demo.assets') IS NOT NULL DROP TABLE demo.assets; IF OBJECT_ID('demo.todos') IS NOT NULL DROP TABLE demo.todos;
IF OBJECT_ID('demo.users') IS NOT NULL DROP TABLE demo.users; IF OBJECT_ID('demo.users') IS NOT NULL DROP TABLE demo.users;
IF SCHEMA_ID('demo') IS NOT NULL DROP SCHEMA demo; IF SCHEMA_ID('demo') IS NOT NULL DROP SCHEMA demo;
GO GO
@@ -17,13 +17,13 @@ CREATE TABLE demo.users (
manager_id INT REFERENCES demo.users (id) manager_id INT REFERENCES demo.users (id)
); );
CREATE TABLE demo.assets ( CREATE TABLE demo.todos (
id INT PRIMARY KEY, id INT PRIMARY KEY,
name NVARCHAR(100) NOT NULL, name NVARCHAR(100) NOT NULL,
status NVARCHAR(20) NOT NULL, status NVARCHAR(20) NOT NULL,
owner_id INT REFERENCES demo.users (id), owner_id INT REFERENCES demo.users (id),
cost DECIMAL(10, 2), estimate DECIMAL(10, 2),
active BIT NOT NULL DEFAULT 1, pinned BIT NOT NULL DEFAULT 1,
created_at DATETIMEOFFSET(0) NOT NULL created_at DATETIMEOFFSET(0) NOT NULL
); );
@@ -32,18 +32,18 @@ CREATE TABLE demo.tags (
name NVARCHAR(100) NOT NULL UNIQUE name NVARCHAR(100) NOT NULL UNIQUE
); );
CREATE TABLE demo.asset_tags ( CREATE TABLE demo.todo_tags (
asset_id INT NOT NULL REFERENCES demo.assets (id), todo_id INT NOT NULL REFERENCES demo.todos (id),
tag_id INT NOT NULL REFERENCES demo.tags (id), tag_id INT NOT NULL REFERENCES demo.tags (id),
PRIMARY KEY (asset_id, tag_id) PRIMARY KEY (todo_id, tag_id)
); );
CREATE TABLE demo.incidents ( CREATE TABLE demo.steps (
id INT PRIMARY KEY, id INT PRIMARY KEY,
title NVARCHAR(200) NOT NULL, title NVARCHAR(200) NOT NULL,
asset_id INT NOT NULL REFERENCES demo.assets (id), todo_id INT NOT NULL REFERENCES demo.todos (id),
severity INT NOT NULL, priority INT NOT NULL,
opened_on DATE NOT NULL due_on DATE NOT NULL
); );
INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VALUES INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VALUES
@@ -51,24 +51,24 @@ INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1), (2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1); (3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'), (1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'), (2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'), (3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00'); (4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'), (1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'), (2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05'); (3, 'Buy a watering can', 1, 4, '2026-03-05');
GO GO
CREATE FUNCTION demo.assets_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN ( CREATE FUNCTION demo.todos_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN (
SELECT a.* FROM demo.assets a SELECT a.* FROM demo.todos a
JOIN demo.asset_tags t ON t.asset_id = a.id JOIN demo.todo_tags t ON t.todo_id = a.id
JOIN demo.tags g ON g.id = t.tag_id JOIN demo.tags g ON g.id = t.tag_id
WHERE g.name = @tag WHERE g.name = @tag
); );
+26 -26
View File
@@ -12,13 +12,13 @@ CREATE TABLE demo.users (
FOREIGN KEY (manager_id) REFERENCES demo.users (id) FOREIGN KEY (manager_id) REFERENCES demo.users (id)
); );
CREATE TABLE demo.assets ( CREATE TABLE demo.todos (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL,
owner_id INT, owner_id INT,
cost DECIMAL(10, 2), estimate DECIMAL(10, 2),
active TINYINT(1) NOT NULL DEFAULT 1, pinned TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
FOREIGN KEY (owner_id) REFERENCES demo.users (id) FOREIGN KEY (owner_id) REFERENCES demo.users (id)
); );
@@ -28,21 +28,21 @@ CREATE TABLE demo.tags (
name VARCHAR(100) NOT NULL UNIQUE name VARCHAR(100) NOT NULL UNIQUE
); );
CREATE TABLE demo.asset_tags ( CREATE TABLE demo.todo_tags (
asset_id INT NOT NULL, todo_id INT NOT NULL,
tag_id INT NOT NULL, tag_id INT NOT NULL,
PRIMARY KEY (asset_id, tag_id), PRIMARY KEY (todo_id, tag_id),
FOREIGN KEY (asset_id) REFERENCES demo.assets (id), FOREIGN KEY (todo_id) REFERENCES demo.todos (id),
FOREIGN KEY (tag_id) REFERENCES demo.tags (id) FOREIGN KEY (tag_id) REFERENCES demo.tags (id)
); );
CREATE TABLE demo.incidents ( CREATE TABLE demo.steps (
id INT PRIMARY KEY, id INT PRIMARY KEY,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
asset_id INT NOT NULL, todo_id INT NOT NULL,
severity INT NOT NULL, priority INT NOT NULL,
opened_on DATE NOT NULL, due_on DATE NOT NULL,
FOREIGN KEY (asset_id) REFERENCES demo.assets (id) FOREIGN KEY (todo_id) REFERENCES demo.todos (id)
); );
SET time_zone = '+00:00'; SET time_zone = '+00:00';
@@ -52,23 +52,23 @@ INSERT INTO demo.users (id, name, email, password_hash, `limit`, manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1), (2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1); (3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02 10:00:00'), (1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02 10:00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05 09:30:00'), (2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05 09:30:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01 00:00:00'), (3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01 00:00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11 16:45:00'); (4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11 16:45:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'), (1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'), (2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05'); (3, 'Buy a watering can', 1, 4, '2026-03-05');
CREATE FUNCTION demo.retire_asset(asset INT, reason TEXT) RETURNS INT MODIFIES SQL DATA CREATE FUNCTION demo.complete_todo(todo INT, note TEXT) RETURNS INT MODIFIES SQL DATA
BEGIN BEGIN
UPDATE demo.assets SET status = 'retired' WHERE id = asset; UPDATE demo.todos SET status = 'done' WHERE id = todo;
RETURN asset; RETURN todo;
END; END;
+25 -25
View File
@@ -10,13 +10,13 @@ CREATE TABLE demo.users (
manager_id integer REFERENCES demo.users (id) manager_id integer REFERENCES demo.users (id)
); );
CREATE TABLE demo.assets ( CREATE TABLE demo.todos (
id integer PRIMARY KEY, id integer PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
status text NOT NULL, status text NOT NULL,
owner_id integer REFERENCES demo.users (id), owner_id integer REFERENCES demo.users (id),
cost numeric(10, 2), estimate numeric(10, 2),
active boolean NOT NULL DEFAULT true, pinned boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL created_at timestamptz NOT NULL
); );
@@ -25,18 +25,18 @@ CREATE TABLE demo.tags (
name text NOT NULL UNIQUE name text NOT NULL UNIQUE
); );
CREATE TABLE demo.asset_tags ( CREATE TABLE demo.todo_tags (
asset_id integer NOT NULL REFERENCES demo.assets (id), todo_id integer NOT NULL REFERENCES demo.todos (id),
tag_id integer NOT NULL REFERENCES demo.tags (id), tag_id integer NOT NULL REFERENCES demo.tags (id),
PRIMARY KEY (asset_id, tag_id) PRIMARY KEY (todo_id, tag_id)
); );
CREATE TABLE demo.incidents ( CREATE TABLE demo.steps (
id integer PRIMARY KEY, id integer PRIMARY KEY,
title text NOT NULL, title text NOT NULL,
asset_id integer NOT NULL REFERENCES demo.assets (id), todo_id integer NOT NULL REFERENCES demo.todos (id),
severity integer NOT NULL, priority integer NOT NULL,
opened_on date NOT NULL due_on date NOT NULL
); );
INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES
@@ -44,27 +44,27 @@ INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1), (2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1); (3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, true, '2026-01-02T10:00:00Z'), (1, 'water plants', 'open', 1, 2.50, true, '2026-01-02T10:00:00Z'),
(2, 'mail relay', 'open', 2, 300.00, true, '2026-01-05T09:30:00Z'), (2, 'file taxes', 'open', 2, 1.00, true, '2026-01-05T09:30:00Z'),
(3, 'old wiki', 'retired', NULL, NULL, false, '2025-06-01T00:00:00Z'), (3, 'fix bike', 'done', NULL, NULL, false, '2025-06-01T00:00:00Z'),
(4, 'payroll db', 'open', 3, 4000.00, true, '2026-02-11T16:45:00Z'); (4, 'pay rent', 'open', 3, 4.00, true, '2026-02-11T16:45:00Z');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'), (1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'), (2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05'); (3, 'Buy a watering can', 1, 4, '2026-03-05');
CREATE FUNCTION demo.retire_asset(asset integer, reason text DEFAULT NULL) RETURNS integer CREATE FUNCTION demo.complete_todo(todo integer, note text DEFAULT NULL) RETURNS integer
LANGUAGE sql AS $$ UPDATE demo.assets SET status = 'retired' WHERE id = asset RETURNING id $$; LANGUAGE sql AS $$ UPDATE demo.todos SET status = 'done' WHERE id = todo RETURNING id $$;
CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets CREATE FUNCTION demo.todos_tagged(tag text) RETURNS SETOF demo.todos
LANGUAGE sql STABLE AS $$ 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 SELECT a.* FROM demo.todos a JOIN demo.todo_tags t ON t.todo_id = a.id JOIN demo.tags g ON g.id = t.tag_id
WHERE g.name = tag ORDER BY a.id $$; WHERE g.name = tag ORDER BY a.id $$;
CREATE TABLE demo.notes ( CREATE TABLE demo.notes (
+21 -21
View File
@@ -7,13 +7,13 @@ CREATE TABLE demo.users (
manager_id INTEGER REFERENCES users (id) manager_id INTEGER REFERENCES users (id)
); );
CREATE TABLE demo.assets ( CREATE TABLE demo.todos (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
owner_id INTEGER REFERENCES users (id), owner_id INTEGER REFERENCES users (id),
cost NUMERIC, estimate NUMERIC,
active BOOLEAN NOT NULL DEFAULT 1, pinned BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
); );
@@ -22,18 +22,18 @@ CREATE TABLE demo.tags (
name TEXT NOT NULL UNIQUE name TEXT NOT NULL UNIQUE
); );
CREATE TABLE demo.asset_tags ( CREATE TABLE demo.todo_tags (
asset_id INTEGER NOT NULL REFERENCES assets (id), todo_id INTEGER NOT NULL REFERENCES todos (id),
tag_id INTEGER NOT NULL REFERENCES tags (id), tag_id INTEGER NOT NULL REFERENCES tags (id),
PRIMARY KEY (asset_id, tag_id) PRIMARY KEY (todo_id, tag_id)
); );
CREATE TABLE demo.incidents ( CREATE TABLE demo.steps (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
asset_id INTEGER NOT NULL REFERENCES assets (id), todo_id INTEGER NOT NULL REFERENCES todos (id),
severity INTEGER NOT NULL, priority INTEGER NOT NULL,
opened_on DATE NOT NULL due_on DATE NOT NULL
); );
INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES
@@ -41,17 +41,17 @@ INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1), (2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1); (3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'), (1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'), (2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'), (3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00'); (4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'), (1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'), (2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05'); (3, 'Buy a watering can', 1, 4, '2026-03-05');
+7 -7
View File
@@ -2,8 +2,8 @@
export const PAGES = ['query-grammar.md', 'writes.md']; export const PAGES = ['query-grammar.md', 'writes.md'];
const PROCEDURES = { const PROCEDURES = {
'assets/{asset}/retire': { function: 'demo.retire_asset' }, 'todos/{todo}/complete': { function: 'demo.complete_todo' },
'tags/{tag}/assets': { function: 'demo.assets_tagged' }, 'tags/{tag}/todos': { function: 'demo.todos_tagged' },
}; };
/** /**
@@ -12,18 +12,18 @@ const PROCEDURES = {
*/ */
const AVAILABLE = { const AVAILABLE = {
postgres: Object.keys(PROCEDURES), postgres: Object.keys(PROCEDURES),
mssql: ['tags/{tag}/assets'], mssql: ['tags/{tag}/todos'],
mysql: ['assets/{asset}/retire'], mysql: ['todos/{todo}/complete'],
sqlite: [], sqlite: [],
}; };
export const OPTIONS = { export const OPTIONS = {
expose: { schemas: ['demo'] }, expose: { schemas: ['demo'] },
resources: { resources: {
asset_tags: { expose: false }, todo_tags: { expose: false },
tags: { write: true }, tags: { write: true },
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } }, todos: { write: { create: ['name', 'status', 'owner_id', 'estimate'], update: ['name', 'status', 'owner_id'], delete: false } },
incidents: { write: { create: true, update: true, delete: true, bulk: true } }, steps: { write: { create: true, update: true, delete: true, bulk: true } },
}, },
}; };
+7 -7
View File
@@ -33,8 +33,8 @@ for (const page of PAGES) {
describe('postgres procedures outside the exposed schemas', { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => { describe('postgres procedures outside the exposed schemas', { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
test('a declared function is read even when only tables are exposed', async () => { test('a declared function is read even when only tables are exposed', async () => {
await pool.query(fixture); await pool.query(fixture);
const rest = createREST({ db: postgres(pool), expose: { tables: ['demo.tags'] }, procedures: { 'assets/{asset}/retire': { function: 'demo.retire_asset' } } }); const rest = createREST({ db: postgres(pool), expose: { tables: ['demo.tags'] }, procedures: { 'todos/{todo}/complete': { function: 'demo.complete_todo' } } });
const res = await rest.handle({ method: 'POST', path: '/assets/3/retire', query: new URLSearchParams(), headers: {}, body: {}, context: undefined }); const res = await rest.handle({ method: 'POST', path: '/todos/3/complete', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body, 3); assert.equal(res.body, 3);
}); });
@@ -44,7 +44,7 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
let rest; let rest;
before(async () => { before(async () => {
await pool.query(fixture); await pool.query(fixture);
rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, procedures: { 'tags/{tag}/assets': { function: 'demo.assets_tagged', method: 'GET' } } }); rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, procedures: { 'tags/{tag}/todos': { function: 'demo.todos_tagged', method: 'GET' } } });
}); });
const get = (path, qs = '') => rest.handle({ method: 'GET', path, query: new URLSearchParams(qs), headers: {}, context: undefined }); const get = (path, qs = '') => rest.handle({ method: 'GET', path, query: new URLSearchParams(qs), headers: {}, context: undefined });
@@ -52,7 +52,7 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
assert.deepEqual((await get('/notes', 'select=id&labels=ov.{a,z}')).body, [{ id: 1 }, { id: 3 }]); assert.deepEqual((await get('/notes', 'select=id&labels=ov.{a,z}')).body, [{ id: 1 }, { id: 3 }]);
assert.deepEqual((await get('/notes', 'select=id&labels=cs.{b}')).body, [{ id: 1 }, { id: 2 }]); assert.deepEqual((await get('/notes', 'select=id&labels=cs.{b}')).body, [{ id: 1 }, { id: 2 }]);
assert.deepEqual((await get('/notes', 'select=id&labels=cd.{a,b,z}')).body, [{ id: 1 }, { id: 3 }]); assert.deepEqual((await get('/notes', 'select=id&labels=cd.{a,b,z}')).body, [{ id: 1 }, { id: 3 }]);
assert.equal((await get('/assets', 'name=ov.{a}')).body.error, 'bad_operator'); assert.equal((await get('/todos', 'name=ov.{a}')).body.error, 'bad_operator');
}); });
test('regular expressions, with and without case', async () => { test('regular expressions, with and without case', async () => {
@@ -61,10 +61,10 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
}); });
test('a GET procedure takes its arguments from the query and refuses POST', async () => { test('a GET procedure takes its arguments from the query and refuses POST', async () => {
const rows = (await get('/tags/gdpr/assets', 'select=ignored')).body; const rows = (await get('/tags/home/todos', 'select=ignored')).body;
assert.equal(rows.error, 'unknown_argument'); assert.equal(rows.error, 'unknown_argument');
assert.deepEqual((await get('/tags/gdpr/assets')).body.map((r) => r.id), [2, 4]); assert.deepEqual((await get('/tags/home/todos')).body.map((r) => r.id), [2, 4]);
const post = await rest.handle({ method: 'POST', path: '/tags/gdpr/assets', query: new URLSearchParams(), headers: {}, body: {}, context: undefined }); const post = await rest.handle({ method: 'POST', path: '/tags/home/todos', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.equal(post.status, 405); assert.equal(post.status, 405);
}); });
}); });
+1 -1
View File
@@ -7,7 +7,7 @@ const response = await rest.handle({ method, path, query, headers, body, context
// { status, headers, body } // { 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. `handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/todos/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
## Node ## Node
+5 -5
View File
@@ -8,16 +8,16 @@ restloom reads the database's catalog once, on the first request, and builds the
```ts ```ts
expose: { schemas: ['app'] } // every table and view of app expose: { schemas: ['app'] } // every table and view of app
expose: { tables: ['app.assets', 'app.users'] } // only these expose: { tables: ['app.todos', '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: 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 ```ts
resources: { asset_tags: { expose: false } } resources: { todo_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. 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: { todo_rows: { key: ['todo_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
## Columns ## Columns
@@ -36,8 +36,8 @@ Relations come from foreign keys between exposed resources:
| In the catalog | Gives | | In the catalog | Gives |
|---|---| |---|---|
| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many | | `todos.owner_id` references `users.id` | `todos.owner`, to-one; `users.todos`, 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 | | `todo_tags` has a primary key made of a key to `todos` and a key to `tags` | `todos.tags` and `tags.todos`, many-to-many |
Names: Names:
+1 -1
View File
@@ -34,7 +34,7 @@ http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000);
Every table and view of `app` is now a resource, read-only: Every table and view of `app` is now a resource, read-only:
```sh ```sh
curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open' curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open'
``` ```
## Next ## Next
+2 -2
View File
@@ -11,8 +11,8 @@ restloom keeps what makes PostgREST pleasant, the catalog as the source and a qu
| Writes | everything the role may write | off until a resource enables them, by column | | 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 | | 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 | | 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 | | Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.work`: at least one; `not.`: none |
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` | | Ordering by a related column | on embedded resources | `order=owner.name`, `order=steps.count` |
| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` | | A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` |
| Over-sized limit | capped | refused with `limit_exceeded` | | Over-sized limit | capped | refused with `limit_exceeded` |
| Errors | database messages | stable codes, no database text | | Errors | database messages | stable codes, no database text |
+34 -34
View File
@@ -6,10 +6,10 @@ The demo schema has four resources:
| Resource | Columns | Relations | | Resource | Columns | Relations |
|---|---|---| |---|---|---|
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `assets` | | `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `todos` |
| `assets` | id, name, status, owner_id, cost, active, created_at | `owner` (one user), `tags` (many, through `asset_tags`), `incidents` (many) | | `todos` | id, name, status, owner_id, estimate, pinned, created_at | `owner` (one user), `tags` (many, through `todo_tags`), `steps` (many) |
| `tags` | id, name | `assets` (many, through `asset_tags`) | | `tags` | id, name | `todos` (many, through `todo_tags`) |
| `incidents` | id, title, asset_id, severity, opened_on | `asset` (one asset) | | `steps` | id, title, todo_id, priority, due_on | `todo` (one todo) |
`users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden. `users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
@@ -20,21 +20,21 @@ Examples are shown unencoded. A client encodes them like any query string: `curl
Without `select`, a row has every visible column. With it, only the listed ones, in that order. Without `select`, a row has every visible column. With it, only the listed ones, in that order.
```http ```http
GET /assets?select=id,name&status=eq.open&order=id GET /todos?select=id,name&status=eq.open&order=id
``` ```
```json ```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }] [{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }, { "id": 4, "name": "pay rent" }]
``` ```
One row is read by its key. It answers 404 when the row does not exist or is not visible. One row is read by its key. It answers 404 when the row does not exist or is not visible.
```http ```http
GET /assets/1?select=id,name,owner(name) GET /todos/1?select=id,name,owner(name)
``` ```
```json ```json
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } } { "id": 1, "name": "water plants", "owner": { "name": "Ada" } }
``` ```
## Related rows: embedding ## Related rows: embedding
@@ -42,7 +42,7 @@ GET /assets/1?select=id,name,owner(name)
`relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default). `relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default).
```http ```http
GET /assets?status=eq.open&select=id,owner(name,manager(name)) GET /todos?status=eq.open&select=id,owner(name,manager(name))
``` ```
```json ```json
@@ -70,15 +70,15 @@ GET /users?select=id,name,manager(name),users(name)
An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000). An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000).
```http ```http
GET /assets?select=id,incidents(title)&incidents.order=severity.desc&incidents.limit=1&order=incidents.count.desc GET /todos?select=id,steps(title)&steps.order=priority.desc&steps.limit=1&order=steps.count.desc
``` ```
```json ```json
[ [
{ "id": 1, "incidents": [{ "title": "Brute force" }] }, { "id": 1, "steps": [{ "title": "Buy a watering can" }] },
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] }, { "id": 2, "steps": [{ "title": "Find the receipts" }] },
{ "id": 3, "incidents": [] }, { "id": 3, "steps": [] },
{ "id": 4, "incidents": [] } { "id": 4, "steps": [] }
] ]
``` ```
@@ -96,16 +96,16 @@ A filter is `column=operator.value`. Every filter must hold.
| `ov.{a,b}`, `cs.{a,b}`, `cd.{a,b}` | the array overlaps, contains, or is contained by the list | array columns (Postgres) | | `ov.{a,b}`, `cs.{a,b}`, `cd.{a,b}` | the array overlaps, contains, or is contained by the list | array columns (Postgres) |
| `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans | | `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans |
`not.` before the operator negates it: `status=not.in.(retired,lost)`. `not.` before the operator negates it: `status=not.in.(done,cancelled)`.
A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused: A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused:
```http ```http
GET /assets?cost=like.1* GET /todos?estimate=like.1*
``` ```
```json ```json
{ "error": "bad_operator", "message": "like on cost" } { "error": "bad_operator", "message": "like on estimate" }
``` ```
### Through relations ### Through relations
@@ -117,29 +117,29 @@ A filter can name a column of a related resource: `relation.column=operator.valu
- **With `not.`** on a relation path: no related row may match. - **With `not.`** on a relation path: no related row may match.
```http ```http
GET /assets?select=id,name&owner.name=ilike.*a* GET /todos?select=id,name&owner.name=ilike.*a*
``` ```
```json ```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }] [{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }]
``` ```
```http ```http
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
``` ```
```json ```json
[ [
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] }, { "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] }, { "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] },
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" }, "tags": [{ "name": "pci" }, { "name": "internal" }] } { "id": 1, "name": "water plants", "owner": { "name": "Ada" }, "tags": [{ "name": "work" }, { "name": "urgent" }] }
] ]
``` ```
Assets with no `pci` tag, including assets with no tag at all: Todos with no `work` tag, including todos with no tag at all:
```http ```http
GET /assets?select=id&tags.name=not.eq.pci GET /todos?select=id&tags.name=not.eq.work
``` ```
```json ```json
@@ -149,7 +149,7 @@ GET /assets?select=id&tags.name=not.eq.pci
`relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same. `relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same.
```http ```http
GET /assets?select=id&tags=is.empty GET /todos?select=id&tags=is.empty
``` ```
```json ```json
@@ -161,7 +161,7 @@ GET /assets?select=id&tags=is.empty
`or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group. `or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group.
```http ```http
GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus) GET /todos?select=id&or=(status.eq.done,owner.name.eq.Linus)
``` ```
```json ```json
@@ -173,16 +173,16 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
`order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`. `order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`.
- **To-one paths:** sort by a related column: `order=owner.name.desc`. - **To-one paths:** sort by a related column: `order=owner.name.desc`.
- **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values. - **To-many relations:** sort by how many related rows each row has: `order=steps.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
- **Stable pages:** the key is appended to every order. - **Stable pages:** the key is appended to every order.
- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database. - **Nulls:** they sort last ascending and first descending, as in Postgres, on every database.
```http ```http
GET /assets?select=id,cost&order=cost.desc GET /todos?select=id,estimate&order=estimate.desc
``` ```
```json ```json
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }] [{ "id": 3, "estimate": null }, { "id": 4, "estimate": 4 }, { "id": 1, "estimate": 2.5 }, { "id": 2, "estimate": 1 }]
``` ```
## Pages and counts ## Pages and counts
@@ -190,7 +190,7 @@ GET /assets?select=id,cost&order=cost.desc
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced: `limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
```http ```http
GET /assets?limit=1001 GET /todos?limit=1001
``` ```
```json ```json
@@ -200,7 +200,7 @@ GET /assets?limit=1001
Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one. Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one.
```http ```http
GET /assets?select=id&limit=2&offset=1&count=exact GET /todos?select=id&limit=2&offset=1&count=exact
``` ```
```json ```json
@@ -214,11 +214,11 @@ The answer above comes with `Content-Range: 1-2/4`.
`count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only. `count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only.
```http ```http
GET /assets?select=status,count(),cost.sum()&order=status GET /todos?select=status,count(),estimate.sum()&order=status
``` ```
```json ```json
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }] [{ "status": "done", "count": 1, "sum": null }, { "status": "open", "count": 3, "sum": 7.5 }]
``` ```
## Names that are keywords ## Names that are keywords
+23 -23
View File
@@ -5,12 +5,12 @@ Writes are off until a resource enables them. The examples on this page run with
```ts ```ts
resources: { resources: {
tags: { write: true }, tags: { write: true },
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } }, todos: { write: { create: ['name', 'status', 'owner_id', 'estimate'], update: ['name', 'status', 'owner_id'], delete: false } },
incidents: { write: { create: true, update: true, delete: true, bulk: true } }, steps: { write: { create: true, update: true, delete: true, bulk: true } },
}, },
procedures: { procedures: {
'assets/{asset}/retire': { function: 'demo.retire_asset' }, 'todos/{todo}/complete': { function: 'demo.complete_todo' },
'tags/{tag}/assets': { function: 'demo.assets_tagged' }, 'tags/{tag}/todos': { function: 'demo.todos_tagged' },
}, },
``` ```
@@ -21,7 +21,7 @@ procedures: {
| POST | `/tags` | creates one row, or an array of rows | 201 | | POST | `/tags` | creates one row, or an array of rows | 201 |
| PATCH | `/tags/4` | updates one row | 204, or 200 with the row | | PATCH | `/tags/4` | updates one row | 204, or 200 with the row |
| DELETE | `/tags/4` | deletes 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 | | PATCH, DELETE | `/steps?priority=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
## Getting the rows back ## Getting the rows back
@@ -63,13 +63,13 @@ POST /tags
A column outside the resource's writable list is refused, whatever its value: A column outside the resource's writable list is refused, whatever its value:
```http ```http
POST /assets POST /todos
{ "name": "new laptop", "status": "open", "active": false } { "name": "book dentist", "status": "open", "pinned": false }
``` ```
```json ```json
{ "error": "not_writable", "message": "active" } { "error": "not_writable", "message": "pinned" }
``` ```
## Updating and deleting ## Updating and deleting
@@ -77,7 +77,7 @@ POST /assets
PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404. PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404.
```http ```http
PATCH /assets/2?select=id,name,owner(name) PATCH /todos/2?select=id,name,owner(name)
Prefer: return=representation Prefer: return=representation
{ "owner_id": 1 } { "owner_id": 1 }
@@ -86,7 +86,7 @@ Prefer: return=representation
Answer: 200 Answer: 200
```json ```json
{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } } { "id": 2, "name": "file taxes", "owner": { "name": "Ada" } }
``` ```
```http ```http
@@ -106,7 +106,7 @@ DELETE /tags/3
``` ```
```http ```http
DELETE /assets/1 DELETE /todos/1
``` ```
```json ```json
@@ -116,18 +116,18 @@ DELETE /assets/1
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. 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 ```http
DELETE /incidents?severity=lt.3&select=id,title DELETE /steps?priority=lt.3&select=id,title
Prefer: return=representation Prefer: return=representation
``` ```
Answer: 200 Answer: 200
```json ```json
[{ "id": 2, "title": "Spoofed mail" }] [{ "id": 2, "title": "Find the receipts" }]
``` ```
```http ```http
DELETE /incidents DELETE /steps
``` ```
```json ```json
@@ -149,9 +149,9 @@ POST /tags
``` ```
```http ```http
POST /incidents POST /steps
{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" } { "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" }
``` ```
```json ```json
@@ -165,9 +165,9 @@ A procedure calls a database function at a path you choose. A `{name}` in the pa
A procedure is a POST unless declared `method: 'GET'`. A GET procedure takes its arguments from the query string (`/search?q=vpn&limit=10`), each once, and runs in a read-only transaction: use it for a function without side effects. A procedure is a POST unless declared `method: 'GET'`. A GET procedure takes its arguments from the query string (`/search?q=vpn&limit=10`), each once, and runs in a read-only transaction: use it for a function without side effects.
```http ```http
POST /assets/3/retire POST /todos/3/complete
{ "reason": "end of life" } { "note": "bought a new one" }
``` ```
```json ```json
@@ -177,20 +177,20 @@ POST /assets/3/retire
A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above. A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above.
```http ```http
POST /tags/gdpr/assets POST /tags/home/todos
``` ```
```json ```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": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": 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" } { "id": 4, "name": "pay rent", "status": "open", "owner_id": 3, "estimate": 4, "pinned": true, "created_at": "2026-02-11T16:45:00+00:00" }
] ]
``` ```
```http ```http
POST /assets/3/retire POST /todos/3/complete
{ "why": "no reason" } { "why": "too busy" }
``` ```
```json ```json
+4 -4
View File
@@ -3,13 +3,13 @@
A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one: A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one:
``` ```
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
``` ```
```json ```json
[ [
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] }, { "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] } { "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] }
] ]
``` ```
@@ -46,7 +46,7 @@ import { postgres } from '@crudy/restloom-postgres';
const rest = createREST({ const rest = createREST({
db: postgres(new pg.Pool()), db: postgres(new pg.Pool()),
expose: { schemas: ['app'] }, expose: { schemas: ['app'] },
resources: { assets: { write: true } }, resources: { todos: { write: true } },
}); });
http.createServer(nodeListener(rest)).listen(3000); http.createServer(nodeListener(rest)).listen(3000);
+1 -1
View File
@@ -62,7 +62,7 @@ export interface ProcedureOptions {
export interface RestRequest<C = unknown> { export interface RestRequest<C = unknown> {
method: string; method: string;
/** Path without the mount prefix, e.g. /assets/42. */ /** Path without the mount prefix, e.g. /todos/42. */
path: string; path: string;
query: URLSearchParams; query: URLSearchParams;
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
+5 -5
View File
@@ -11,14 +11,14 @@ export function catalog() {
{ schema: 'demo', name: 'users', kind: 'table', primaryKey: ['id'], { schema: 'demo', name: 'users', kind: 'table', primaryKey: ['id'],
columns: [col('id', 'integer'), col('name'), col('password_hash'), col('limit', 'integer'), col('manager_id', 'integer')], columns: [col('id', 'integer'), col('name'), col('password_hash'), col('limit', 'integer'), col('manager_id', 'integer')],
foreignKeys: [{ columns: ['manager_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] }, foreignKeys: [{ columns: ['manager_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
{ schema: 'demo', name: 'assets', kind: 'table', primaryKey: ['id'], { schema: 'demo', name: 'todos', kind: 'table', primaryKey: ['id'],
columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('cost', 'number'), col('active', 'boolean'), col('created_at', 'timestamp', { generated: true })], columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('estimate', 'number'), col('pinned', 'boolean'), col('created_at', 'timestamp', { generated: true })],
foreignKeys: [{ columns: ['owner_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] }, foreignKeys: [{ columns: ['owner_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
{ schema: 'demo', name: 'tags', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer'), col('name')], foreignKeys: [] }, { schema: 'demo', name: 'tags', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer'), col('name')], foreignKeys: [] },
{ schema: 'demo', name: 'asset_tags', kind: 'table', primaryKey: ['asset_id', 'tag_id'], { schema: 'demo', name: 'todo_tags', kind: 'table', primaryKey: ['todo_id', 'tag_id'],
columns: [col('asset_id', 'integer'), col('tag_id', 'integer')], columns: [col('todo_id', 'integer'), col('tag_id', 'integer')],
foreignKeys: [ foreignKeys: [
{ columns: ['asset_id'], target: { schema: 'demo', table: 'assets' }, targetColumns: ['id'] }, { columns: ['todo_id'], target: { schema: 'demo', table: 'todos' }, targetColumns: ['id'] },
{ columns: ['tag_id'], target: { schema: 'demo', table: 'tags' }, targetColumns: ['id'] }, { columns: ['tag_id'], target: { schema: 'demo', table: 'tags' }, targetColumns: ['id'] },
] }, ] },
{ schema: 'other', name: 'secrets', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer')], foreignKeys: [] }, { schema: 'other', name: 'secrets', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer')], foreignKeys: [] },
+14 -14
View File
@@ -17,36 +17,36 @@ test('unknown, hidden and quoted names', () => {
}); });
test('values are checked against the column type', () => { test('values are checked against the column type', () => {
assert.equal(code(() => parse('assets', 'id=eq.abc')), 'invalid_value'); assert.equal(code(() => parse('todos', 'id=eq.abc')), 'invalid_value');
assert.equal(code(() => parse('assets', 'active=eq.yes')), 'invalid_value'); assert.equal(code(() => parse('todos', 'pinned=eq.yes')), 'invalid_value');
assert.equal(code(() => parse('assets', 'cost=like.1*')), 'bad_operator'); assert.equal(code(() => parse('todos', 'estimate=like.1*')), 'bad_operator');
assert.equal(parse('assets', 'id=eq.4').where[0].value, 4); assert.equal(parse('todos', 'id=eq.4').where[0].value, 4);
}); });
test('relation paths, presence and depth', () => { test('relation paths, presence and depth', () => {
const f = parse('assets', 'owner.manager.name=eq.Ada').where[0]; const f = parse('todos', 'owner.manager.name=eq.Ada').where[0];
assert.deepEqual(f.path.relations.map((x) => x.name), ['owner', 'manager']); assert.deepEqual(f.path.relations.map((x) => x.name), ['owner', 'manager']);
assert.equal(code(() => parse('assets', 'owner.manager.manager.name=eq.Ada')), 'too_deep'); assert.equal(code(() => parse('todos', 'owner.manager.manager.name=eq.Ada')), 'too_deep');
assert.equal(parse('assets', 'tags=is.empty').where[0].kind, 'present'); assert.equal(parse('todos', 'tags=is.empty').where[0].kind, 'present');
assert.equal(code(() => parse('assets', 'order=tags.name')), 'bad_order'); assert.equal(code(() => parse('todos', 'order=tags.name')), 'bad_order');
assert.equal(parse('assets', 'order=tags.count.desc').order[0].key.kind, 'count'); assert.equal(parse('todos', 'order=tags.count.desc').order[0].key.kind, 'count');
}); });
test('limits are refused, not clamped', () => { test('limits are refused, not clamped', () => {
assert.equal(code(() => parse('assets', 'limit=1001')), 'limit_exceeded'); assert.equal(code(() => parse('todos', 'limit=1001')), 'limit_exceeded');
const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&'); const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&');
assert.equal(code(() => parse('assets', many)), 'too_many_filters'); assert.equal(code(() => parse('todos', many)), 'too_many_filters');
}); });
test('the key is appended to the order; a grouped read orders by its columns', () => { test('the key is appended to the order; a grouped read orders by its columns', () => {
assert.deepEqual(parse('assets', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']); assert.deepEqual(parse('todos', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']);
assert.equal(code(() => parse('assets', 'select=status,count()&order=name')), 'bad_order'); assert.equal(code(() => parse('todos', 'select=status,count()&order=name')), 'bad_order');
}); });
test('placeholders follow the text order, for positional databases too', () => { test('placeholders follow the text order, for positional databases too', () => {
class Positional extends SqlRenderer { placeholder() { return '?'; } } class Positional extends SqlRenderer { placeholder() { return '?'; } }
const renderer = new Positional(); const renderer = new Positional();
const plan = parse('assets', 'select=id,owner(name)&status=eq.open'); const plan = parse('todos', 'select=id,owner(name)&status=eq.open');
const scope = (res) => res.name === 'users' ? [{ kind: 'cmp', path: { relations: [], column: 'name' }, op: 'eq', negate: false, value: 'scoped' }] : []; const scope = (res) => res.name === 'users' ? [{ kind: 'cmp', path: { relations: [], column: 'name' }, op: 'eq', negate: false, value: 'scoped' }] : [];
const { sql, params } = renderer.read(plan, renderer.context(scope)); const { sql, params } = renderer.read(plan, renderer.context(scope));
assert.equal(sql.split('?').length - 1, params.length); assert.equal(sql.split('?').length - 1, params.length);
+7 -7
View File
@@ -4,7 +4,7 @@ import { resources } from './fixture.mjs';
test('only exposed schemas become resources', () => { test('only exposed schemas become resources', () => {
const r = resources(); const r = resources();
assert.deepEqual([...r.keys()].sort(), ['asset_tags', 'assets', 'tags', 'users']); assert.deepEqual([...r.keys()].sort(), ['tags', 'todo_tags', 'todos', 'users']);
}); });
test('sensitive columns are hidden unless shown, and hide removes any column', () => { test('sensitive columns are hidden unless shown, and hide removes any column', () => {
@@ -16,12 +16,12 @@ test('sensitive columns are hidden unless shown, and hide removes any column', (
test('foreign keys give to-one, to-many and many-to-many relations', () => { test('foreign keys give to-one, to-many and many-to-many relations', () => {
const r = resources(); const r = resources();
const rel = (res, name) => { const x = r.get(res).relations.get(name); return x && `${x.kind}:${x.target.name}${x.through ? ':through' : ''}`; }; const rel = (res, name) => { const x = r.get(res).relations.get(name); return x && `${x.kind}:${x.target.name}${x.through ? ':through' : ''}`; };
assert.equal(rel('assets', 'owner'), 'one:users'); assert.equal(rel('todos', 'owner'), 'one:users');
assert.equal(rel('users', 'assets'), 'many:assets'); assert.equal(rel('users', 'todos'), 'many:todos');
assert.equal(rel('users', 'manager'), 'one:users'); assert.equal(rel('users', 'manager'), 'one:users');
assert.equal(rel('users', 'users'), 'many:users'); assert.equal(rel('users', 'users'), 'many:users');
assert.equal(rel('assets', 'tags'), 'many:tags:through'); assert.equal(rel('todos', 'tags'), 'many:tags:through');
assert.equal(rel('tags', 'assets'), 'many:assets:through'); assert.equal(rel('tags', 'todos'), 'many:todos:through');
}); });
test('relations can be renamed, removed and declared', () => { test('relations can be renamed, removed and declared', () => {
@@ -32,8 +32,8 @@ test('relations can be renamed, removed and declared', () => {
}); });
test('writes are off by default and never reach generated or key columns', () => { test('writes are off by default and never reach generated or key columns', () => {
assert.equal(resources().get('assets').write.create, false); assert.equal(resources().get('todos').write.create, false);
const a = resources({ resources: { assets: { write: true } } }).get('assets'); const a = resources({ resources: { todos: { write: true } } }).get('todos');
assert.equal(a.columns.get('name').create, true); assert.equal(a.columns.get('name').create, true);
assert.equal(a.columns.get('created_at').create, false); assert.equal(a.columns.get('created_at').create, false);
assert.equal(a.columns.get('id').update, false); assert.equal(a.columns.get('id').update, false);