pgkiln 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +2 -2
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +179 -5
- package/docs/guide/08-security.md +11 -0
- package/docs/guide/12-development.md +44 -6
- package/docs/guide/13-rest-api.md +59 -1
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +420 -173
- package/public/builder.js +48 -0
- package/public/sql-monitor.js +73 -0
- package/src/app.ts +6 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +3 -2
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/datapump.ts +364 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +6 -2
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +6 -0
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +6 -4
- package/src/builder/worksheet.ts +5 -21
- package/src/changelog.ts +109 -0
- package/src/cron-schedule.ts +282 -0
- package/src/datapump.ts +546 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +116 -22
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type pg from 'pg';
|
|
2
|
+
import { substitute, type BindValues } from '../binds.ts';
|
|
3
|
+
|
|
4
|
+
// (sprint 104, Database Actions DA-11 part 2; migration 117) The handler source types REST modules gained with
|
|
5
|
+
// SQL Workshop › RESTful Services, next to collection, item and sql (rest.ts):
|
|
6
|
+
//
|
|
7
|
+
// plpgsql a PL/pgSQL block run as an anonymous DO block as the application's role. A DO block takes no
|
|
8
|
+
// parameters, so the binds go in as transaction-local settings, set with query parameters (never part
|
|
9
|
+
// of the SQL text), and :NAME in the block becomes meta.rest_param('NAME'). The block answers with
|
|
10
|
+
// meta.rest_reply(body jsonb, status int); without a reply: 204.
|
|
11
|
+
// media a query whose first row has content_type and content (bytea or text), optional filename: the
|
|
12
|
+
// response is that content. It is served sandboxed (CSP sandbox, nosniff), so an HTML or SVG
|
|
13
|
+
// "image" from the table can't run script in the application's origin.
|
|
14
|
+
|
|
15
|
+
/** Bind names a PL/pgSQL handler can read (a setting name each): letters, digits and _. */
|
|
16
|
+
const BIND_NAME = /^[A-Z_][A-Z0-9_]{0,62}$/;
|
|
17
|
+
/** The dollar-quote tag around a PL/pgSQL handler's block. */
|
|
18
|
+
export const DO_TAG = '$pgkiln_rest$';
|
|
19
|
+
/** At most this many binds go into settings (a body with thousands of fields is not a handler's input). */
|
|
20
|
+
const MAX_BINDS = 200;
|
|
21
|
+
|
|
22
|
+
export class HandlerInputError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** The DO statement of a PL/pgSQL handler: its binds as meta.rest_param('NAME'), wrapped in begin … end if needed. */
|
|
25
|
+
export function plpgsqlBlock(source: string): string {
|
|
26
|
+
if (source.includes(DO_TAG)) throw new Error(`A PL/pgSQL handler may not contain ${DO_TAG}.`);
|
|
27
|
+
let body = substitute(source.trim().replace(/;+\s*$/, ''), (name) => `meta.rest_param('${name}')`);
|
|
28
|
+
if (!/^(declare|begin|<<)/i.test(body.replace(/^(\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/))*\s*/, ''))) body = `begin\n${body};\nend`;
|
|
29
|
+
return `do ${DO_TAG}\n${body}\n${DO_TAG}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set the binds as transaction-local settings pgkiln_bind.<name>, as query parameters. */
|
|
33
|
+
export async function setBinds(c: pg.PoolClient, binds: BindValues) {
|
|
34
|
+
const entries = Object.entries(binds).filter(([k]) => BIND_NAME.test(k)).slice(0, MAX_BINDS);
|
|
35
|
+
for (const [, v] of entries) if (typeof v === 'string' && v.includes('\0')) throw new HandlerInputError('A parameter contains a NUL byte.');
|
|
36
|
+
if (!entries.length) return;
|
|
37
|
+
await c.query(`select set_config('pgkiln_bind.' || lower(k), coalesce(v, ''), true) from unnest($1::text[], $2::text[]) as b(k, v)`,
|
|
38
|
+
[entries.map(([k]) => k), entries.map(([, v]) => v ?? null)]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Run a PL/pgSQL handler; its reply (meta.rest_reply) or none. */
|
|
42
|
+
export async function runPlpgsql(c: pg.PoolClient, source: string): Promise<{ body: unknown; status: number | null }> {
|
|
43
|
+
// a reply from an earlier statement of this transaction must not leak into this one
|
|
44
|
+
await c.query(`select set_config('pgkiln_rest.body', '', true), set_config('pgkiln_rest.status', '', true)`);
|
|
45
|
+
await c.query(plpgsqlBlock(source));
|
|
46
|
+
const r = (await c.query(`select current_setting('pgkiln_rest.body', true) as body, current_setting('pgkiln_rest.status', true) as status`)).rows[0];
|
|
47
|
+
return { body: r.body ? JSON.parse(r.body) : undefined, status: r.status ? Number(r.status) : null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A content type safe to send as a header: type/subtype with optional parameters, no line breaks. */
|
|
51
|
+
export function safeContentType(v: unknown): string {
|
|
52
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
53
|
+
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(\s*;\s*[a-z0-9_-]+=("[^"\r\n]*"|[^;\s\r\n]+))*$/i.test(s) && s.length <= 200 ? s : 'application/octet-stream';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file name for Content-Disposition: printable ASCII without quotes, slashes or backslashes. */
|
|
57
|
+
export const safeFilename = (v: unknown) => (typeof v === 'string' ? v.replace(/[^\x20-\x7e]|["\\/;]/g, '_').slice(0, 200) : '');
|
|
58
|
+
|
|
59
|
+
/** The response of a media handler's row. */
|
|
60
|
+
export function mediaResponse(row: Record<string, unknown>) {
|
|
61
|
+
if (!('content' in row) || !('content_type' in row)) throw new Error('A media handler\'s query returns the columns content_type and content (and optionally filename).');
|
|
62
|
+
const c = row.content;
|
|
63
|
+
const body = c === null || c === undefined ? Buffer.alloc(0) : Buffer.isBuffer(c) ? c : Buffer.from(typeof c === 'string' ? c : JSON.stringify(c));
|
|
64
|
+
const name = safeFilename(row.filename);
|
|
65
|
+
return {
|
|
66
|
+
body,
|
|
67
|
+
headers: {
|
|
68
|
+
'content-type': safeContentType(row.content_type),
|
|
69
|
+
'x-content-type-options': 'nosniff',
|
|
70
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
|
|
71
|
+
'cache-control': 'no-store',
|
|
72
|
+
...(name ? { 'content-disposition': `inline; filename="${name}"` } : {}),
|
|
73
|
+
} as Record<string, string>,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/runtime/rest.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { loadApp, type App } from '../metadata.ts';
|
|
|
10
10
|
import { clientIp } from '../session.ts';
|
|
11
11
|
import { PassThrough } from 'node:stream';
|
|
12
12
|
import { publicError, writeOut } from './context.ts';
|
|
13
|
+
import { DO_TAG, HandlerInputError, mediaResponse, runPlpgsql, setBinds } from './rest-handlers.ts';
|
|
13
14
|
|
|
14
|
-
// REST modules (APEX: RESTful Services): handlers defined in the builder,
|
|
15
|
+
// REST modules (APEX: RESTful Services): handlers defined in the builder (Shared Components, or (sprint 104)
|
|
16
|
+
// SQL Workshop › RESTful Services › Modules as templates and handlers; plpgsql and media types: rest-handlers.ts),
|
|
15
17
|
// served under /a/<alias>/rest/<module>/<path>.
|
|
16
18
|
//
|
|
17
19
|
// {"method": "GET", "path": "employees", "type": "collection", "source": "select … order by empno"}
|
|
@@ -33,17 +35,36 @@ import { publicError, writeOut } from './context.ts';
|
|
|
33
35
|
export interface Handler {
|
|
34
36
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
35
37
|
path: string;
|
|
36
|
-
|
|
38
|
+
/** (117) plpgsql: a PL/pgSQL block (rest-handlers.ts); media: a row's content_type and content */
|
|
39
|
+
type: 'collection' | 'item' | 'sql' | 'plpgsql' | 'media';
|
|
37
40
|
source: string;
|
|
38
41
|
roles?: string[];
|
|
39
42
|
auth?: 'token' | 'public';
|
|
40
43
|
description?: string;
|
|
41
44
|
page_size?: number;
|
|
42
45
|
status?: number;
|
|
46
|
+
/** (117) documented parameters: in the OpenAPI document and the Try it form; a required one missing is a 400 */
|
|
47
|
+
params?: HandlerParam[];
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
export interface HandlerParam {
|
|
51
|
+
name: string;
|
|
52
|
+
in: 'query' | 'body';
|
|
53
|
+
type?: 'string' | 'integer' | 'number' | 'boolean';
|
|
54
|
+
required?: boolean;
|
|
55
|
+
description?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** (117) A template of a module: its URI and description (handlers name it in "path"). */
|
|
59
|
+
export interface Template {
|
|
60
|
+
uri: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
65
|
+
export const TYPES = ['collection', 'item', 'sql', 'plpgsql', 'media'];
|
|
66
|
+
export const PARAM_TYPES = ['string', 'integer', 'number', 'boolean'];
|
|
67
|
+
const PARAM_NAME = /^[a-z_][a-z0-9_]{0,62}$/i;
|
|
47
68
|
const SEGMENT = /^(:[a-z][a-z0-9_]*|[a-z0-9][a-z0-9._-]*)$/i;
|
|
48
69
|
const MAX_PAGE = 500;
|
|
49
70
|
/** Rows fetched from a collection's cursor at a time. */
|
|
@@ -65,7 +86,18 @@ export function handlerProblems(handlers: unknown): string[] {
|
|
|
65
86
|
problems.push(`${at}: ${[...RESERVED_BINDS].join(', ')} are set by the server and can't be path parameters.`);
|
|
66
87
|
if (!TYPES.includes(h.type)) problems.push(`${at}: "type" is one of ${TYPES.join(', ')}.`);
|
|
67
88
|
if (typeof h.source !== 'string' || !h.source.trim()) problems.push(`${at}: "source" is the SQL.`);
|
|
68
|
-
if (h.type
|
|
89
|
+
if ((h.type === 'collection' || h.type === 'item' || h.type === 'media') && h.method !== 'GET') problems.push(`${at}: collection, item and media handlers answer GET; use "sql" or "plpgsql" for ${h.method}.`);
|
|
90
|
+
if (h.type === 'plpgsql' && typeof h.source === 'string' && h.source.includes(DO_TAG)) problems.push(`${at}: a PL/pgSQL block may not contain ${DO_TAG}.`);
|
|
91
|
+
if (h.params !== undefined) {
|
|
92
|
+
if (!Array.isArray(h.params)) problems.push(`${at}: "params" is a list of {"name", "in": "query" or "body", "type", "required"}.`);
|
|
93
|
+
else h.params.forEach((p: any) => {
|
|
94
|
+
if (!p || typeof p !== 'object' || typeof p.name !== 'string' || !PARAM_NAME.test(p.name)) problems.push(`${at}: a parameter's name is letters, digits and _.`);
|
|
95
|
+
else if (RESERVED_BINDS.has(p.name.toUpperCase())) problems.push(`${at}: ${p.name} is set by the server and can't be a parameter.`);
|
|
96
|
+
else if (p.name.toUpperCase() === 'BODY') problems.push(`${at}: :BODY is the whole JSON body; name the parameter otherwise.`);
|
|
97
|
+
if (p && p.in !== 'query' && p.in !== 'body') problems.push(`${at}: a parameter is "in" query or body.`);
|
|
98
|
+
if (p?.type !== undefined && !PARAM_TYPES.includes(p.type)) problems.push(`${at}: a parameter's type is one of ${PARAM_TYPES.join(', ')}.`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
69
101
|
if (h.roles !== undefined && !(Array.isArray(h.roles) && h.roles.every((r: unknown) => typeof r === 'string'))) problems.push(`${at}: "roles" is a list of role names.`);
|
|
70
102
|
if (h.auth !== undefined && h.auth !== 'token' && h.auth !== 'public') problems.push(`${at}: "auth" is token (default) or public.`);
|
|
71
103
|
if (h.status !== undefined && !(Number.isInteger(h.status) && h.status >= 200 && h.status < 300)) problems.push(`${at}: "status" is a 2xx status.`);
|
|
@@ -76,6 +108,28 @@ export function handlerProblems(handlers: unknown): string[] {
|
|
|
76
108
|
return problems;
|
|
77
109
|
}
|
|
78
110
|
|
|
111
|
+
/** (117) Problems in a module's templates, or []. */
|
|
112
|
+
export function templateProblems(templates: unknown): string[] {
|
|
113
|
+
if (!Array.isArray(templates)) return ['The templates must be a JSON array.'];
|
|
114
|
+
const problems: string[] = [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
templates.forEach((t: any, i) => {
|
|
117
|
+
const uri = t?.uri;
|
|
118
|
+
if (typeof uri !== 'string' || !uri || !uri.split('/').every((s: string) => SEGMENT.test(s)))
|
|
119
|
+
problems.push(`Template ${i + 1}: the URI is like emp or emp/:id (letters, digits, - _ . and :parameters).`);
|
|
120
|
+
else if (uri === 'openapi.json') problems.push(`Template ${i + 1}: openapi.json is reserved for the module's description.`);
|
|
121
|
+
else if (uri.split('/').some((s: string) => s.startsWith(':') && RESERVED_BINDS.has(s.slice(1).toUpperCase())))
|
|
122
|
+
problems.push(`Template ${i + 1}: ${[...RESERVED_BINDS].join(', ')} can't be URI parameters.`);
|
|
123
|
+
else {
|
|
124
|
+
const key = uri.replace(/:[^/]+/g, ':');
|
|
125
|
+
if (seen.has(key)) problems.push(`Template ${i + 1}: ${uri} is there twice.`);
|
|
126
|
+
seen.add(key);
|
|
127
|
+
}
|
|
128
|
+
if (t?.description !== undefined && typeof t.description !== 'string') problems.push(`Template ${i + 1}: the description is text.`);
|
|
129
|
+
});
|
|
130
|
+
return problems;
|
|
131
|
+
}
|
|
132
|
+
|
|
79
133
|
/** The handler for a method and path, with its path parameters. */
|
|
80
134
|
export function matchHandler(handlers: Handler[], method: string, path: string) {
|
|
81
135
|
const parts = path.split('/').filter(Boolean);
|
|
@@ -145,7 +199,7 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
145
199
|
const { alias, module: name } = req.params as { alias: string; module: string };
|
|
146
200
|
const sub = ((req.params as Record<string, string>)['*'] ?? '').replace(/\/+$/, '');
|
|
147
201
|
const a = await loadApp(alias);
|
|
148
|
-
const mod = a ? await runtime.one<{ handlers: Handler[]; enabled: boolean }>('select handlers, enabled from meta.rest_module where app_id = $1 and name = $2', [a.id, name]) : undefined;
|
|
202
|
+
const mod = a ? await runtime.one<{ handlers: Handler[]; enabled: boolean; roles: string[] | null }>('select handlers, enabled, roles from meta.rest_module where app_id = $1 and name = $2', [a.id, name]) : undefined;
|
|
149
203
|
if (!a || !mod?.enabled || !(await restServicesOn(a.id))) return reply.code(404).send({ error: 'Not found' });
|
|
150
204
|
const m = matchHandler(mod.handlers ?? [], req.method, sub);
|
|
151
205
|
if (m === 'method') return reply.code(405).send({ error: `${req.method} is not allowed here.` });
|
|
@@ -165,6 +219,9 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
165
219
|
binds.BODY = JSON.stringify(body);
|
|
166
220
|
}
|
|
167
221
|
Object.assign(binds, params);
|
|
222
|
+
// (117) documented parameters marked required
|
|
223
|
+
const missing = (h.params ?? []).filter((p) => p.required && (binds[p.name.toUpperCase()] ?? '') === '').map((p) => p.name);
|
|
224
|
+
if (missing.length) throw new RestError(400, `Missing parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}.`);
|
|
168
225
|
// the built-in names are the server's, never the caller's (?app_user=… must not set :APP_USER)
|
|
169
226
|
for (const k of Object.keys(binds)) if (RESERVED_BINDS.has(k)) delete binds[k];
|
|
170
227
|
Object.assign(binds, { APP_USER: who.user, APP_ID: String(a.id), APP_ALIAS: a.alias, APP_SESSION: null, APP_PAGE_ID: null, REQUEST: null });
|
|
@@ -174,11 +231,24 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
174
231
|
set_config('request.jwt.claims', $3, true), set_config('statement_timeout', '30s', true)`,
|
|
175
232
|
[String(a.id), who.user, who.claims ? JSON.stringify(who.claims) : ''],
|
|
176
233
|
);
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
// (117) the module's privilege, then the handler's own roles
|
|
235
|
+
for (const roles of [mod.roles, h.roles]) if (roles?.length) {
|
|
236
|
+
const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [roles])).rows[0].ok;
|
|
179
237
|
if (!ok) throw new RestError(403, 'You do not have a role this endpoint needs.');
|
|
180
238
|
}
|
|
239
|
+
if (h.type === 'plpgsql') await setBinds(c, binds);
|
|
181
240
|
if (a.db_role) await c.query(`set local role ${pg.escapeIdentifier(a.db_role)}`);
|
|
241
|
+
// (117) handlers run as a constrained role: never a superuser, a role that bypasses row level security, or
|
|
242
|
+
// pgkiln's owner (a runtime connection without RUNTIME_DATABASE_URL is the owner's)
|
|
243
|
+
const role = (await c.query(
|
|
244
|
+
`select r.rolsuper or r.rolbypassrls or pg_has_role(current_user, n.nspowner, 'MEMBER') as bad
|
|
245
|
+
from pg_roles r, pg_namespace n where r.rolname = current_user and n.nspname = 'meta'`)).rows[0];
|
|
246
|
+
if (!role || role.bad) throw new RestError(403, 'This endpoint would run as a role that bypasses row level security or owns pgkiln: refused. Give the application its own database role.');
|
|
247
|
+
if (h.type === 'plpgsql') {
|
|
248
|
+
const out = await runPlpgsql(c, h.source);
|
|
249
|
+
const status = out.status ?? (out.body === undefined ? 204 : h.status ?? (req.method === 'POST' ? 201 : 200));
|
|
250
|
+
return { status, body: out.body };
|
|
251
|
+
}
|
|
182
252
|
const sql = applyBinds(h.source.trim().replace(/;+\s*$/, ''), binds);
|
|
183
253
|
if (h.type === 'collection') {
|
|
184
254
|
const size = Math.max(1, Math.min(MAX_PAGE, Number(binds.LIMIT) || h.page_size || 25));
|
|
@@ -211,9 +281,10 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
211
281
|
}
|
|
212
282
|
return { status: 200, body: undefined, streamed: true };
|
|
213
283
|
}
|
|
214
|
-
if (h.type === 'item') {
|
|
284
|
+
if (h.type === 'item' || h.type === 'media') {
|
|
215
285
|
const rows = (await c.query(`select * from (\n${sql}\n) "__r" limit 2`)).rows;
|
|
216
286
|
if (!rows.length) throw new RestError(404, 'Not found');
|
|
287
|
+
if (h.type === 'media') return { status: 200, body: undefined, media: mediaResponse(rows[0]) };
|
|
217
288
|
return { status: 200, body: rows[0] };
|
|
218
289
|
}
|
|
219
290
|
let last: pg.QueryResult | undefined;
|
|
@@ -222,9 +293,11 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
222
293
|
return row ? { status: h.status ?? (req.method === 'POST' ? 201 : 200), body: row } : { status: 204, body: undefined };
|
|
223
294
|
});
|
|
224
295
|
if ('streamed' in result) return reply;
|
|
225
|
-
|
|
296
|
+
if ('media' in result && result.media) return reply.code(200).headers(result.media.headers).send(result.media.body);
|
|
297
|
+
return result.body === undefined ? reply.code(result.status === 204 || result.status < 200 ? 204 : result.status).send() : reply.code(result.status).send(result.body);
|
|
226
298
|
} catch (e) {
|
|
227
299
|
if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
|
|
300
|
+
if (e instanceof HandlerInputError) return reply.code(400).send({ error: e.message });
|
|
228
301
|
const err = e as pg.DatabaseError;
|
|
229
302
|
const message = await publicError({ app: a, page: { page_no: 0 } as never, user: 'api', ip: clientIp(req) }, e, `REST ${req.method} ${name}/${sub}`);
|
|
230
303
|
const status = err.code === '42501' ? 403 : err.code && /^(P0001|22|23)/.test(err.code) ? 400 : 500;
|
|
@@ -233,26 +306,47 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
/** The OpenAPI 3 description of a module. */
|
|
236
|
-
export function openApi(
|
|
309
|
+
export function openApi(
|
|
310
|
+
a: Pick<App, 'alias' | 'name'>,
|
|
311
|
+
mod: { name: string; title: string; description: string | null; handlers: Handler[]; roles?: string[] | null; templates?: Template[] | null },
|
|
312
|
+
) {
|
|
237
313
|
const paths: Record<string, Record<string, unknown>> = {};
|
|
314
|
+
const toPath = (uri: string) => `/${uri.replace(/:([a-z][a-z0-9_]*)/gi, '{$1}')}`;
|
|
315
|
+
const modRoles = mod.roles ?? [];
|
|
316
|
+
for (const t of Array.isArray(mod.templates) ? mod.templates : []) if (t?.uri && t.description) (paths[toPath(t.uri)] ??= {}).description = t.description;
|
|
238
317
|
for (const h of mod.handlers) {
|
|
239
|
-
const p =
|
|
240
|
-
const params = [...h.path.matchAll(/:([a-z][a-z0-9_]*)/gi)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
|
|
318
|
+
const p = toPath(h.path);
|
|
319
|
+
const params: Record<string, unknown>[] = [...h.path.matchAll(/:([a-z][a-z0-9_]*)/gi)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
|
|
241
320
|
if (h.type === 'collection')
|
|
242
|
-
params.push({ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }
|
|
321
|
+
params.push({ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }, { name: 'offset', in: 'query', required: false, schema: { type: 'integer' } });
|
|
322
|
+
const documented = Array.isArray(h.params) ? h.params : [];
|
|
323
|
+
for (const q of documented.filter((x) => x.in === 'query'))
|
|
324
|
+
params.push({ name: q.name, in: 'query', required: !!q.required, schema: { type: q.type ?? 'string' }, ...(q.description ? { description: q.description } : {}) });
|
|
325
|
+
const bodyParams = documented.filter((x) => x.in === 'body');
|
|
326
|
+
const bodySchema = bodyParams.length
|
|
327
|
+
? { type: 'object', properties: Object.fromEntries(bodyParams.map((q) => [q.name, { type: q.type ?? 'string', ...(q.description ? { description: q.description } : {}) }])),
|
|
328
|
+
...(bodyParams.some((q) => q.required) ? { required: bodyParams.filter((q) => q.required).map((q) => q.name) } : {}) }
|
|
329
|
+
: { type: 'object' };
|
|
330
|
+
const roles = [modRoles.length ? `one of the roles ${modRoles.join(', ')}` : '', h.roles?.length ? `one of the roles ${h.roles.join(', ')}` : ''].filter(Boolean);
|
|
331
|
+
const ok = h.type === 'sql' || h.type === 'plpgsql' ? String(h.status ?? (h.method === 'POST' ? 201 : 200)) : '200';
|
|
243
332
|
paths[p] ??= {};
|
|
244
333
|
paths[p][h.method.toLowerCase()] = {
|
|
245
334
|
summary: h.description ?? `${h.method} ${h.path || '/'}`,
|
|
335
|
+
...(roles.length ? { description: `The caller needs ${roles.join(' and ')}.`, 'x-pgkiln-roles': [modRoles, h.roles ?? []].filter((r) => r.length) } : {}),
|
|
246
336
|
...(params.length ? { parameters: params } : {}),
|
|
247
|
-
...(h.method !== 'GET' ? { requestBody: { required:
|
|
337
|
+
...(h.method !== 'GET' ? { requestBody: { required: bodyParams.some((q) => q.required), content: { 'application/json': { schema: bodySchema } } } } : {}),
|
|
248
338
|
...(h.auth === 'public' ? { security: [] } : {}),
|
|
249
339
|
responses: {
|
|
250
|
-
[h.type === '
|
|
251
|
-
description:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
340
|
+
[ok]: h.type === 'media'
|
|
341
|
+
? { description: 'The content (content type from the row)', content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }
|
|
342
|
+
: {
|
|
343
|
+
description: h.type === 'collection' ? '{items, offset, limit, has_more}' : h.type === 'plpgsql' ? 'The body of meta.rest_reply' : 'The row as an object',
|
|
344
|
+
content: { 'application/json': { schema: { type: 'object' } } },
|
|
345
|
+
},
|
|
346
|
+
...(h.type === 'sql' || h.type === 'plpgsql' ? { 204: { description: 'No content' } } : {}),
|
|
347
|
+
...(documented.some((q) => q.required) || h.type === 'plpgsql' || h.type === 'sql' ? { 400: { description: 'Missing or invalid input' } } : {}),
|
|
348
|
+
...(h.type === 'item' || h.type === 'media' ? { 404: { description: 'Not found' } } : {}),
|
|
349
|
+
...(h.auth === 'public' && !roles.length ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } }),
|
|
256
350
|
},
|
|
257
351
|
};
|
|
258
352
|
}
|
|
@@ -269,7 +363,7 @@ export function openApi(a: Pick<App, 'alias' | 'name'>, mod: { name: string; tit
|
|
|
269
363
|
export async function restRoutes(app: FastifyInstance) {
|
|
270
364
|
app.get<{ Params: { alias: string; module: string } }>('/a/:alias/rest/:module/openapi.json', async (req, reply) => {
|
|
271
365
|
const a = await loadApp(req.params.alias);
|
|
272
|
-
const mod = a ? await runtime.one<{ name: string; title: string; description: string | null; handlers: Handler[]; enabled: boolean }>('select * from meta.rest_module where app_id = $1 and name = $2', [a.id, req.params.module]) : undefined;
|
|
366
|
+
const mod = a ? await runtime.one<{ name: string; title: string; description: string | null; handlers: Handler[]; enabled: boolean; roles: string[]; templates: Template[] }>('select * from meta.rest_module where app_id = $1 and name = $2', [a.id, req.params.module]) : undefined;
|
|
273
367
|
if (!a || !mod?.enabled || !(await restServicesOn(a.id))) return reply.code(404).send({ error: english('error.not_found') });
|
|
274
368
|
return reply.send(openApi(a, mod));
|
|
275
369
|
});
|