feat: writes, procedures, Fastify and Express adapters, documentation
This commit is contained in:
+32
-16
@@ -1,33 +1,49 @@
|
||||
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
|
||||
* conformance case: the request, then the exact body it must answer. A body
|
||||
* with an `error` key must come with a 4xx or 5xx status.
|
||||
* The conformance cases of a docs page: every ```http block (request line,
|
||||
* header lines, a blank line and a JSON body) followed by an optional
|
||||
* `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) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const out = [];
|
||||
const re = /```http\n([\s\S]*?)```\s*\n+```json\n([\s\S]*?)```/g;
|
||||
for (let m; (m = re.exec(text));) {
|
||||
const line = text.slice(0, m.index).split('\n').length;
|
||||
const request = m[1].split('\n').map((l) => l.trim()).filter(Boolean).join('');
|
||||
const space = request.indexOf(' ');
|
||||
const target = new URL(request.slice(space + 1), 'http://example');
|
||||
for (let m; (m = CASE.exec(text));) {
|
||||
if (!m[2] && m[3] === undefined) continue;
|
||||
const { line, headers, body } = request(m[1]);
|
||||
const space = line.indexOf(' ');
|
||||
const target = new URL(line.slice(space + 1), 'http://example');
|
||||
out.push({
|
||||
name: `${file.split('/').pop()}:${line} ${request}`,
|
||||
method: request.slice(0, space),
|
||||
path: target.pathname,
|
||||
query: target.searchParams,
|
||||
expected: JSON.parse(m[2]),
|
||||
name: `${file.split('/').pop()}:${text.slice(0, m.index).split('\n').length} ${line}`,
|
||||
method: line.slice(0, space), path: target.pathname, query: target.searchParams, headers, body,
|
||||
status: m[2] ? Number(m[2]) : undefined,
|
||||
expected: m[3] === undefined ? undefined : JSON.parse(m[3]),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -58,3 +58,11 @@ INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
|
||||
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
||||
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
||||
(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 { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createREST } from '../packages/core/dist/index.js';
|
||||
import { postgres } from '../packages/postgres/dist/index.js';
|
||||
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 { Pool } = require('pg');
|
||||
const url = process.env.RESTLOOM_PG_URL;
|
||||
const docs = new URL('../docs/', import.meta.url).pathname;
|
||||
const pages = ['query-grammar.md'];
|
||||
const fixture = readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8');
|
||||
|
||||
let pool;
|
||||
let rest;
|
||||
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 } } });
|
||||
});
|
||||
before(() => { if (url) pool = new Pool({ connectionString: url }); });
|
||||
after(async () => { await pool?.end(); });
|
||||
|
||||
for (const page of pages) {
|
||||
for (const example of examples(docs + page)) {
|
||||
test(`postgres ${example.name}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => run(rest, example, assert));
|
||||
}
|
||||
for (const page of PAGES) {
|
||||
describe(`postgres ${page}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user