pgkiln 0.47.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/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- 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 +11 -11
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +267 -7
- package/docs/guide/08-security.md +29 -0
- package/docs/guide/12-development.md +70 -6
- package/docs/guide/13-rest-api.md +133 -10
- 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 +442 -173
- package/public/builder.js +48 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/sql-monitor.js +73 -0
- package/public/worksheet.css +49 -0
- package/public/worksheet.js +4 -0
- package/src/app.ts +10 -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 +363 -0
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/datapump.ts +364 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +7 -1
- 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-charts.ts +203 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +53 -0
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +20 -9
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +25 -17
- package/src/builder/worksheet.ts +35 -28
- package/src/builder/workspaces.ts +4 -2
- package/src/changelog.ts +109 -0
- package/src/cli/replace.ts +3 -1
- package/src/cron-schedule.ts +282 -0
- package/src/datamodel.ts +324 -0
- package/src/datapump.ts +546 -0
- package/src/dbroles.ts +215 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +120 -26
- package/src/session.ts +2 -0
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);
|
|
@@ -94,19 +148,19 @@ export function matchHandler(handlers: Handler[], method: string, path: string)
|
|
|
94
148
|
return methodMismatch ? 'method' : null;
|
|
95
149
|
}
|
|
96
150
|
|
|
97
|
-
class RestError extends Error {
|
|
151
|
+
export class RestError extends Error {
|
|
98
152
|
constructor(readonly status: number, message: string) {
|
|
99
153
|
super(message);
|
|
100
154
|
}
|
|
101
155
|
}
|
|
102
156
|
|
|
103
|
-
interface Caller {
|
|
157
|
+
export interface Caller {
|
|
104
158
|
user: string;
|
|
105
159
|
claims: Record<string, unknown> | null;
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
/** The caller of a request: the bearer token checked like meta.api_check does. */
|
|
109
|
-
async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
|
|
163
|
+
export async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
|
|
110
164
|
const auth = String(req.headers.authorization ?? '');
|
|
111
165
|
if (!/^bearer /i.test(auth)) {
|
|
112
166
|
if (publicHandler) return { user: 'nobody', claims: null };
|
|
@@ -136,7 +190,7 @@ async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Prom
|
|
|
136
190
|
const bindValue = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
137
191
|
|
|
138
192
|
/** (093) The workspace preference "RESTful services": off, the workspace's REST modules don't exist. */
|
|
139
|
-
async function restServicesOn(appId: number) {
|
|
193
|
+
export async function restServicesOn(appId: number) {
|
|
140
194
|
const r = await owner.one<{ on: boolean }>('select coalesce((select w.rest_services from meta.workspace w where w.id = meta.app_workspace($1)), true) as on', [appId]);
|
|
141
195
|
return r?.on !== false;
|
|
142
196
|
}
|
|
@@ -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
|
});
|
package/src/session.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface Session {
|
|
|
32
32
|
/** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
|
|
33
33
|
workspaces?: BuilderWorkspace[];
|
|
34
34
|
workspace?: BuilderWorkspace | null;
|
|
35
|
+
/** Builder only (sprint 99): an administrator of the installation (the Administration menu's Database users) */
|
|
36
|
+
instanceAdmin?: boolean;
|
|
35
37
|
/** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */
|
|
36
38
|
devbar_hash?: string | null;
|
|
37
39
|
/** (085) when the session started, in ms since the epoch (its maximum length counts from here) */
|