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,629 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { apiRoleProblem, issueApiToken } from '../api.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
5
|
+
import { icon } from '../icons.ts';
|
|
6
|
+
import { handlerProblems, METHODS, PARAM_TYPES, templateProblems, type Handler, type HandlerParam, type Template } from '../runtime/rest.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
|
+
import { publicUrl } from '../sso.ts';
|
|
9
|
+
import { lockText } from './locks.ts';
|
|
10
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
11
|
+
import { splitRoles } from './users.ts';
|
|
12
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 104, Database Actions DA-11 part 2; migration 117) SQL Workshop › RESTful Services › Modules: an
|
|
15
|
+
// application's REST modules (meta.rest_module, served by runtime/rest.ts) edited the ORDS way, as a tree of
|
|
16
|
+
// module (base path /a/<alias>/rest/<name>/) › templates (URIs with :parameters) › handlers (method and source
|
|
17
|
+
// type: collection query, single row, SQL, PL/pgSQL, media). The module's roles are its privilege; a handler adds
|
|
18
|
+
// its own roles or is public. Each handler page has a Try it panel: the request runs through the real endpoint
|
|
19
|
+
// (fastify inject) with no token or a short-lived token of an account of the application, as App › REST API issues
|
|
20
|
+
// them. Modules belong to the application, so they travel with its export. Only applications of the developer's
|
|
21
|
+
// workspace; a Run only or locked application refuses changes.
|
|
22
|
+
|
|
23
|
+
export const MODULES = `${BASE}/sql/rest/modules`;
|
|
24
|
+
const NAME = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
25
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
26
|
+
collection: 'Collection query: rows, paged',
|
|
27
|
+
item: 'Single row: an object, 404 without a row',
|
|
28
|
+
sql: 'SQL: the first row of the last statement',
|
|
29
|
+
plpgsql: 'PL/pgSQL block: meta.rest_reply(body, status)',
|
|
30
|
+
media: 'Media resource: content_type and content',
|
|
31
|
+
};
|
|
32
|
+
const MAX_TRY_BODY = 20_000;
|
|
33
|
+
|
|
34
|
+
interface ModRow {
|
|
35
|
+
id: number;
|
|
36
|
+
app_id: number;
|
|
37
|
+
name: string;
|
|
38
|
+
title: string;
|
|
39
|
+
description: string | null;
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
handlers: Handler[];
|
|
42
|
+
roles: string[];
|
|
43
|
+
templates: Template[];
|
|
44
|
+
app_alias: string;
|
|
45
|
+
app_name: string;
|
|
46
|
+
db_role: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** RESTful Services' two parts as tabs: Modules and AutoREST. */
|
|
50
|
+
export const restNav = (active: 'modules' | 'autorest') =>
|
|
51
|
+
html`<nav class="settings-tabs" aria-label="RESTful Services">
|
|
52
|
+
<a href="${MODULES}"${active === 'modules' ? raw(' aria-current="page"') : ''}>Modules</a>
|
|
53
|
+
<a href="${BASE}/sql/rest"${active === 'autorest' ? raw(' aria-current="page"') : ''}>AutoREST (tables and views)</a></nav>`;
|
|
54
|
+
|
|
55
|
+
/** The modules of the current workspace's applications (one, with id). */
|
|
56
|
+
async function modules(s: Session, id?: number): Promise<ModRow[]> {
|
|
57
|
+
return (await owner.query<ModRow>(
|
|
58
|
+
`select m.*, a.alias as app_alias, a.name as app_name, a.db_role from meta.rest_module m join meta.app a on a.id = m.app_id
|
|
59
|
+
where meta.app_workspace(a.id) = $1 ${id !== undefined ? 'and m.id = $2' : ''} order by lower(a.name), m.name`,
|
|
60
|
+
id !== undefined ? [currentWorkspace(s), id] : [currentWorkspace(s)])).rows;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function workspaceApps(s: Session) {
|
|
64
|
+
return (await owner.query<{ id: number; alias: string; name: string }>(
|
|
65
|
+
'select id, alias, name from meta.app a where meta.app_workspace(a.id) = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Why the application can't be changed now (Run only, or locked by another developer), or null. */
|
|
69
|
+
async function changeRefused(s: Session, appId: number): Promise<string | null> {
|
|
70
|
+
const a = await owner.one<{ run_only: boolean }>(`select build_status = 'run_only' as run_only from meta.app where id = $1`, [appId]);
|
|
71
|
+
if (a?.run_only) return `${lockText({ page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true })} Set it back to Run and build to change it.`;
|
|
72
|
+
const lock = await owner.one(`select page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = 0 and locked_by <> $2`, [appId, s.username]);
|
|
73
|
+
return lock ? `${lockText(lock)} Changes are refused until it is unlocked.` : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The module's templates: the declared ones and every handler's path, sorted. */
|
|
77
|
+
export function templatesOf(m: Pick<ModRow, 'templates' | 'handlers'>): Template[] {
|
|
78
|
+
const list: Template[] = (Array.isArray(m.templates) ? m.templates : []).filter((t) => typeof t?.uri === 'string').map((t) => ({ ...t }));
|
|
79
|
+
for (const h of Array.isArray(m.handlers) ? m.handlers : []) if (!list.some((t) => t.uri === h.path)) list.push({ uri: h.path });
|
|
80
|
+
return list.sort((a, b) => a.uri.localeCompare(b.uri));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const errorList = (problems: string[]) =>
|
|
84
|
+
problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : '';
|
|
85
|
+
const q = (v: Record<string, string>) => new URLSearchParams(v).toString();
|
|
86
|
+
const tplUrl = (m: ModRow, uri: string) => `${MODULES}/${m.id}/template?${q({ t: uri })}`;
|
|
87
|
+
const handlerUrl = (m: ModRow, h: Pick<Handler, 'path' | 'method'>) => `${MODULES}/${m.id}/handler?${q({ t: h.path, m: h.method })}`;
|
|
88
|
+
const baseUrl = (m: Pick<ModRow, 'app_alias' | 'name'>) => `${publicUrl()}/a/${m.app_alias}/rest/${m.name}/`;
|
|
89
|
+
const access = (m: ModRow, h: Handler) =>
|
|
90
|
+
[h.auth === 'public' ? 'public' : 'token', m.roles.length ? `module role ${m.roles.join(' or ')}` : '', h.roles?.length ? `role ${h.roles.join(' or ')}` : ''].filter(Boolean).join(', ');
|
|
91
|
+
|
|
92
|
+
/** The module's tree: module › templates › handlers. */
|
|
93
|
+
function tree(m: ModRow, sel: { t?: string; m?: string } = {}) {
|
|
94
|
+
const here = (on: boolean) => (on ? raw(' aria-current="page"') : '');
|
|
95
|
+
return html`<nav aria-label="Templates and handlers" class="rm-tree"><ul class="tree">
|
|
96
|
+
<li><a href="${MODULES}/${m.id}"${here(sel.t === undefined)}>${icon('folder')}<span>${m.name}/</span></a>
|
|
97
|
+
<ul>${templatesOf(m).map((t) => html`<li><a href="${tplUrl(m, t.uri)}"${here(sel.t === t.uri && !sel.m)}>${icon('chevron')}<span>${t.uri || '/'}</span></a>
|
|
98
|
+
<ul>${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<li><a href="${handlerUrl(m, h)}"${here(sel.t === t.uri && sel.m === h.method)}><code>${h.method}</code><span>${h.type}</span></a></li>`)}</ul></li>`)}
|
|
99
|
+
</ul></li></ul></nav>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The page frame: tabs, the module's tree beside the content. */
|
|
103
|
+
function frame(s: Session, m: ModRow, title: string, crumbs: [string, string?][], sel: { t?: string; m?: string }, body: Raw) {
|
|
104
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}${restNav('modules')}
|
|
105
|
+
<h2 class="u-mb1 wrap-anywhere">${m.title} <span class="muted small">${m.app_name} · /a/${m.app_alias}/rest/${m.name}/${m.enabled ? '' : ' · disabled'}</span></h2>
|
|
106
|
+
<div class="rm-layout">${region('Module', tree(m, sel))}<div class="rm-main">${body}</div></div>`;
|
|
107
|
+
return shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', MODULES], ...crumbs], main, 'sql');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ------------------------------------------------------------------ pages
|
|
111
|
+
|
|
112
|
+
async function listPage(s: Session, b: Record<string, unknown> = {}, problems: string[] = []) {
|
|
113
|
+
const [list, apps] = await Promise.all([modules(s), workspaceApps(s)]);
|
|
114
|
+
const table = list.length
|
|
115
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Module</th><th>Base path</th><th>Application</th><th>Templates</th><th>Handlers</th><th>Privilege</th></tr></thead><tbody>
|
|
116
|
+
${list.map((m) => html`<tr${m.enabled ? '' : raw(' class="muted"')}>
|
|
117
|
+
<td data-label="Module"><a href="${MODULES}/${m.id}">${m.title}</a>${m.enabled ? '' : html` <span class="tag">off</span>`}</td>
|
|
118
|
+
<td data-label="Base path" class="wrap-anywhere"><code>/a/${m.app_alias}/rest/${m.name}/</code></td>
|
|
119
|
+
<td data-label="Application">${m.app_name}</td>
|
|
120
|
+
<td data-label="Templates">${templatesOf(m).length}</td>
|
|
121
|
+
<td data-label="Handlers">${Array.isArray(m.handlers) ? m.handlers.length : 0}</td>
|
|
122
|
+
<td data-label="Privilege">${m.roles.length ? `role ${m.roles.join(' or ')}` : 'any token'}</td></tr>`)}
|
|
123
|
+
</tbody></table></div>`
|
|
124
|
+
: html`<p class="muted">No REST modules yet. Create one below: a base path, then templates and handlers.</p>`;
|
|
125
|
+
const v = (k: string) => String(b[k] ?? '');
|
|
126
|
+
const form = html`<form method="post" action="${MODULES}" id="create">${csrf(s)}${errorList(problems)}
|
|
127
|
+
<div class="form-grid">
|
|
128
|
+
<div class="field"><label class="label" for="f_app">Application</label>
|
|
129
|
+
<select id="f_app" name="app_id" required>${apps.map((a) => html`<option value="${a.id}"${v('app_id') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias})</option>`)}</select>
|
|
130
|
+
<small class="help">Its database role runs the handlers; its tokens and roles decide who may call them.</small></div>
|
|
131
|
+
<div class="field"><label class="label" for="f_name">Base path</label><input id="f_name" name="name" required value="${v('name')}" placeholder="e.g. sales">
|
|
132
|
+
<small class="help">/a/<app>/rest/<b>name</b>/ — lower case letters, digits, _ and - (at most 40)</small></div>
|
|
133
|
+
<div class="field"><label class="label" for="f_title">Title</label><input id="f_title" name="title" required value="${v('title')}" placeholder="e.g. Sales API"></div>
|
|
134
|
+
<div class="field"><label class="label" for="f_roles">Privilege: roles</label><input id="f_roles" name="roles" value="${v('roles')}" placeholder="any caller with a token">
|
|
135
|
+
<small class="help">Comma separated: every handler needs one of these application roles.</small></div>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description')}"></div>
|
|
138
|
+
<div class="buttons"><button class="btn btn-hot"${apps.length ? '' : raw(' disabled')}>${icon('plus')} Create module</button></div></form>`;
|
|
139
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}${restNav('modules')}
|
|
140
|
+
<p class="muted">RESTful Services modules: a module is a base path of an application, its templates are URIs with <code>:parameters</code>, and each template has handlers per method with their source: a collection query, a single row, SQL, a PL/pgSQL block or a media resource. Handlers run as the application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply; parameters are bound, never pasted into the SQL. Every module has an OpenAPI document. Modules belong to their application: they travel with its export (also in Shared Components › RESTful Services).</p>
|
|
141
|
+
${region('Modules', table)}${region('Create a module', form)}`;
|
|
142
|
+
return shell(s, 'RESTful Services: Modules', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', `${BASE}/sql/rest`], ['Modules']], main, 'sql');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Checks of the role the handlers run as. */
|
|
146
|
+
async function roleChecks(m: ModRow) {
|
|
147
|
+
const problem = m.db_role ? await apiRoleProblem(m.db_role) : null;
|
|
148
|
+
return html`<ul class="checklist">
|
|
149
|
+
<li>${problem ? '✗' : m.db_role ? '✓' : 'ℹ'} ${problem
|
|
150
|
+
? html`The application's database role: ${problem} Its handlers answer 403.`
|
|
151
|
+
: m.db_role ? html`Handlers run as <code>${m.db_role}</code> with <code>meta.app_user()</code> the caller` : html`The application has no database role: handlers run as pgkiln's runtime role. Give it its own role (Application › Settings) so grants and row level security are its own.`}</li>
|
|
152
|
+
<li>${m.enabled ? '✓' : '✗'} ${m.enabled ? 'Enabled' : 'Disabled: every handler answers 404'}</li>
|
|
153
|
+
<li>ℹ ${m.roles.length ? `Privilege: callers need the role ${m.roles.join(' or ')}` : 'No privilege roles: any valid token of the application (handlers may add roles)'}</li>
|
|
154
|
+
</ul>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function modulePage(s: Session, m: ModRow, problems: string[] = [], b?: Record<string, unknown>) {
|
|
158
|
+
const v = (k: keyof ModRow & string, d: unknown) => String(b?.[k] ?? d ?? '');
|
|
159
|
+
const openapi = `${baseUrl(m)}openapi.json`;
|
|
160
|
+
const settings = html`<form method="post" action="${MODULES}/${m.id}">${csrf(s)}${errorList(problems)}
|
|
161
|
+
<div class="form-grid">
|
|
162
|
+
<div class="field"><label class="label" for="f_name">Base path</label><input id="f_name" name="name" required value="${v('name', m.name)}"><small class="help">/a/${m.app_alias}/rest/<b>name</b>/</small></div>
|
|
163
|
+
<div class="field"><label class="label" for="f_title">Title</label><input id="f_title" name="title" required value="${v('title', m.title)}"></div>
|
|
164
|
+
<div class="field"><label class="label" for="f_roles">Privilege: roles</label><input id="f_roles" name="roles" value="${b ? String(b.roles ?? '') : m.roles.join(', ')}" placeholder="any caller with a token"><small class="help">Comma separated: every handler needs one of these roles.</small></div>
|
|
165
|
+
<div class="field"><label class="label" for="f_enabled">Status</label><select id="f_enabled" name="enabled"><option value="1"${m.enabled ? raw(' selected') : ''}>Enabled</option><option value="0"${m.enabled ? '' : raw(' selected')}>Disabled (404)</option></select></div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description', m.description)}"></div>
|
|
168
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div></form>`;
|
|
169
|
+
const templates = templatesOf(m);
|
|
170
|
+
const tplTable = templates.length
|
|
171
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Template</th><th>Handlers</th><th>Description</th></tr></thead><tbody>
|
|
172
|
+
${templates.map((t) => html`<tr><td data-label="Template"><a href="${tplUrl(m, t.uri)}"><code>${t.uri || '/'}</code></a></td>
|
|
173
|
+
<td data-label="Handlers">${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<a href="${handlerUrl(m, h)}"><code>${h.method}</code></a> `)}</td>
|
|
174
|
+
<td data-label="Description">${t.description ?? ''}</td></tr>`)}</tbody></table></div>`
|
|
175
|
+
: html`<p class="muted">No templates yet.</p>`;
|
|
176
|
+
const addTpl = html`<form method="post" action="${MODULES}/${m.id}/template" class="u-mt1">${csrf(s)}
|
|
177
|
+
<div class="form-grid">
|
|
178
|
+
<div class="field"><label class="label" for="f_uri">URI template</label><input id="f_uri" name="uri" required placeholder="e.g. orders/:id"><small class="help">Segments and <code>:parameters</code> (bound as <code>:ID</code>)</small></div>
|
|
179
|
+
<div class="field"><label class="label" for="f_tdesc">Description</label><input id="f_tdesc" name="description"></div>
|
|
180
|
+
</div><div class="buttons"><button class="btn">${icon('plus')} Add template</button></div></form>`;
|
|
181
|
+
const body = html`
|
|
182
|
+
${region('Checks', await roleChecks(m))}
|
|
183
|
+
${region('Templates', html`${tplTable}${addTpl}`)}
|
|
184
|
+
${region('OpenAPI', html`<p class="wrap-anywhere u-mt0">The module's OpenAPI 3 document (without the SQL): <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a></p>
|
|
185
|
+
<p class="muted small">Import it into Swagger UI, Postman or a client generator. Handlers' documented parameters become its query parameters and request bodies.</p>`)}
|
|
186
|
+
${region('Module', html`${settings}
|
|
187
|
+
<form method="post" action="${MODULES}/${m.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the module ${m.name} with all its templates and handlers?">${icon('trash')} Delete module</button></form>`)}`;
|
|
188
|
+
return frame(s, m, `REST module ${m.name}`, [[m.name]], {}, body);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function templatePage(s: Session, m: ModRow, uri: string, problems: string[] = []) {
|
|
192
|
+
const t = templatesOf(m).find((x) => x.uri === uri)!;
|
|
193
|
+
const handlers = m.handlers.filter((h) => h.path === uri);
|
|
194
|
+
const used = new Set<string>(handlers.map((h) => h.method));
|
|
195
|
+
const free = METHODS.filter((x) => !used.has(x));
|
|
196
|
+
const body = html`${errorList(problems)}
|
|
197
|
+
${region('Handlers', html`${handlers.length
|
|
198
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>Source type</th><th>Access</th><th>Description</th></tr></thead><tbody>
|
|
199
|
+
${handlers.map((h) => html`<tr><td data-label="Method"><a href="${handlerUrl(m, h)}"><code>${h.method}</code></a></td><td data-label="Source type">${TYPE_LABELS[h.type] ?? h.type}</td>
|
|
200
|
+
<td data-label="Access">${access(m, h)}</td><td data-label="Description">${h.description ?? ''}</td></tr>`)}</tbody></table></div>`
|
|
201
|
+
: html`<p class="muted">No handlers yet: the URI answers 404.</p>`}
|
|
202
|
+
${free.length ? html`<form method="get" action="${MODULES}/${m.id}/handler" class="search u-mwnone u-mt1"><input type="hidden" name="t" value="${uri}">
|
|
203
|
+
<div class="field"><label class="label" for="f_new">Method</label><select id="f_new" name="new">${free.map((x) => html`<option>${x}</option>`)}</select></div>
|
|
204
|
+
<button class="btn u-selfend">${icon('plus')} Add handler</button></form>` : ''}
|
|
205
|
+
<p class="wrap-anywhere muted small">URL: <code>${baseUrl(m)}${uri}</code></p>`)}
|
|
206
|
+
${region('Template', html`<form method="post" action="${MODULES}/${m.id}/template">${csrf(s)}<input type="hidden" name="orig" value="${uri}">
|
|
207
|
+
<div class="form-grid">
|
|
208
|
+
<div class="field"><label class="label" for="f_uri">URI template</label><input id="f_uri" name="uri" required value="${uri}"><small class="help">Renaming it moves its handlers too.</small></div>
|
|
209
|
+
<div class="field"><label class="label" for="f_tdesc">Description</label><input id="f_tdesc" name="description" value="${t?.description ?? ''}"></div>
|
|
210
|
+
</div><div class="buttons"><button class="btn btn-hot">Save</button></div></form>
|
|
211
|
+
<form method="post" action="${MODULES}/${m.id}/template/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="uri" value="${uri}">
|
|
212
|
+
<button class="btn btn-danger" data-confirm="Delete the template ${uri} and its ${handlers.length} handler(s)?">${icon('trash')} Delete template</button></form>`)}`;
|
|
213
|
+
return frame(s, m, `REST ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/']], { t: uri }, body);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** "name in [type] [required] [description]" per line ↔ the handler's params. */
|
|
217
|
+
export function parseParams(text: string): { params: HandlerParam[]; problems: string[] } {
|
|
218
|
+
const params: HandlerParam[] = [];
|
|
219
|
+
const problems: string[] = [];
|
|
220
|
+
for (const [i, line] of text.split(/\r?\n/).entries()) {
|
|
221
|
+
const words = line.trim().split(/\s+/).filter(Boolean);
|
|
222
|
+
if (!words.length) continue;
|
|
223
|
+
const [name, where, ...rest] = words;
|
|
224
|
+
if (where !== 'query' && where !== 'body') {
|
|
225
|
+
problems.push(`Parameters, line ${i + 1}: write "name query" or "name body", then optionally a type (${PARAM_TYPES.join(', ')}), "required" and a description.`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const p: HandlerParam = { name, in: where };
|
|
229
|
+
if (rest[0] && PARAM_TYPES.includes(rest[0])) p.type = rest.shift() as HandlerParam['type'];
|
|
230
|
+
if (rest[0] === 'required') {
|
|
231
|
+
p.required = true;
|
|
232
|
+
rest.shift();
|
|
233
|
+
}
|
|
234
|
+
if (rest.length) p.description = rest.join(' ').slice(0, 500);
|
|
235
|
+
params.push(p);
|
|
236
|
+
}
|
|
237
|
+
return { params, problems };
|
|
238
|
+
}
|
|
239
|
+
const paramsText = (ps: HandlerParam[] | undefined) =>
|
|
240
|
+
(ps ?? []).map((p) => [p.name, p.in, p.type ?? '', p.required ? 'required' : '', p.description ?? ''].filter(Boolean).join(' ')).join('\n');
|
|
241
|
+
|
|
242
|
+
/** The path parameters of a URI template. */
|
|
243
|
+
const pathParams = (uri: string) => [...uri.matchAll(/:([a-z][a-z0-9_]*)/gi)].map((x) => x[1]);
|
|
244
|
+
|
|
245
|
+
interface TryResult {
|
|
246
|
+
method: string;
|
|
247
|
+
url: string;
|
|
248
|
+
as: string;
|
|
249
|
+
status?: number;
|
|
250
|
+
type?: string;
|
|
251
|
+
body?: string;
|
|
252
|
+
error?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function handlerPage(s: Session, m: ModRow, uri: string, method: string | null, opts: { problems?: string[]; form?: Record<string, unknown>; tried?: TryResult; tryForm?: Record<string, unknown> } = {}) {
|
|
256
|
+
const existing = method ? m.handlers.find((h) => h.path === uri && h.method === method) : undefined;
|
|
257
|
+
const isNew = !existing;
|
|
258
|
+
const f = opts.form;
|
|
259
|
+
const h: Partial<Handler> = existing ?? { method: (method ?? 'GET') as Handler['method'], path: uri, type: method && method !== 'GET' ? 'plpgsql' : 'collection', source: '' };
|
|
260
|
+
const v = (k: string, d: unknown) => String(f ? (f[k] ?? '') : (d ?? ''));
|
|
261
|
+
const type = v('type', h.type);
|
|
262
|
+
const editor = html`<form method="post" action="${MODULES}/${m.id}/handler">${csrf(s)}${errorList(opts.problems ?? [])}
|
|
263
|
+
<input type="hidden" name="t" value="${uri}">${existing ? html`<input type="hidden" name="orig_m" value="${existing.method}">` : ''}
|
|
264
|
+
<div class="form-grid">
|
|
265
|
+
<div class="field"><label class="label" for="f_method">Method</label><select id="f_method" name="method">${METHODS.map((x) => html`<option${v('method', h.method) === x ? raw(' selected') : ''}>${x}</option>`)}</select></div>
|
|
266
|
+
<div class="field"><label class="label" for="f_type">Source type</label><select id="f_type" name="type">${Object.entries(TYPE_LABELS).map(([k, l]) => html`<option value="${k}"${type === k ? raw(' selected') : ''}>${l}</option>`)}</select></div>
|
|
267
|
+
<div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description', h.description)}"></div>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="field"><label class="label" for="f_source">Source</label>
|
|
270
|
+
<textarea id="f_source" name="source" class="code sql-editor" rows="10" spellcheck="false" data-code="${type === 'plpgsql' ? 'plpgsql' : 'sql'}" required>${v('source', h.source)}</textarea>
|
|
271
|
+
<small class="help">Binds, upper case: path parameters (${pathParams(uri).map((p) => `:${p.toUpperCase()}`).join(', ') || 'none here'}), query parameters and JSON body fields; <code>:BODY</code> is the whole body; <code>:APP_USER</code> is the caller. Values are bound, never pasted into the SQL. PL/pgSQL: a block (<code>declare … begin … end</code>, or statements) that answers with <code>perform meta.rest_reply(jsonb_build_object(…), 201)</code>. Media: a query returning <code>content_type</code>, <code>content</code> and optionally <code>filename</code>.</small></div>
|
|
272
|
+
<fieldset class="prop-group"><legend>Who may call it</legend>
|
|
273
|
+
<label class="check"><input type="radio" name="auth" value="token"${v('auth', h.auth ?? 'token') !== 'public' ? raw(' checked') : ''}> A bearer token of the application${m.roles.length ? ` with the module's role ${m.roles.join(' or ')}` : ''}</label>
|
|
274
|
+
<label class="check"><input type="radio" name="auth" value="public"${v('auth', h.auth) === 'public' ? raw(' checked') : ''}> Public: no token <small class="help">(runs as <code>nobody</code>${m.roles.length ? '; the module\'s roles still apply, so it answers 403' : ''})</small></label>
|
|
275
|
+
</fieldset>
|
|
276
|
+
<div class="form-grid">
|
|
277
|
+
<div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" value="${f ? String(f.roles ?? '') : (h.roles ?? []).join(', ')}" placeholder="no further role"><small class="help">Comma separated: the caller needs one of them too.</small></div>
|
|
278
|
+
<div class="field"><label class="label" for="f_page_size">Page size</label><input id="f_page_size" name="page_size" type="number" min="1" max="500" value="${v('page_size', h.page_size)}" placeholder="25"><small class="help">Collection queries: rows without ?limit=.</small></div>
|
|
279
|
+
<div class="field"><label class="label" for="f_status">Status</label><input id="f_status" name="status" type="number" min="200" max="299" value="${v('status', h.status)}" placeholder="201 for POST, else 200"><small class="help">SQL and PL/pgSQL handlers.</small></div>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="field"><label class="label" for="f_params">Parameters (documentation)</label>
|
|
282
|
+
<textarea id="f_params" name="params" class="code" rows="3" spellcheck="false" placeholder="start_date body string required First day">${f ? String(f.params ?? '') : paramsText(h.params)}</textarea>
|
|
283
|
+
<small class="help">One per line: <code>name query|body [${PARAM_TYPES.join('|')}] [required] [description]</code>. They go into the OpenAPI document and the Try it form; a required one missing answers 400.</small></div>
|
|
284
|
+
<div class="buttons"><button class="btn btn-hot">${isNew ? 'Create handler' : 'Save'}</button></div></form>
|
|
285
|
+
${existing ? html`<form method="post" action="${MODULES}/${m.id}/handler/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="t" value="${uri}"><input type="hidden" name="m" value="${existing.method}">
|
|
286
|
+
<button class="btn btn-danger" data-confirm="Delete the handler ${existing.method} ${uri}?">${icon('trash')} Delete handler</button></form>` : ''}`;
|
|
287
|
+
const body = html`${region(isNew ? `New handler for ${uri || '/'}` : `${h.method} ${uri || '/'}`, editor)}${existing ? await tryPanel(s, m, existing, opts.tried, opts.tryForm) : ''}`;
|
|
288
|
+
return frame(s, m, `REST ${h.method} ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/', tplUrl(m, uri)], [isNew ? 'New handler' : String(h.method)]], { t: uri, m: existing?.method }, body);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Accounts a Try it request may run as: those with access to the application (all active ones for "any user"). */
|
|
292
|
+
async function tryAccounts(appId: number) {
|
|
293
|
+
return (await owner.query<{ username: string }>(
|
|
294
|
+
`select ac.username from meta.account ac, meta.app a where a.id = $1 and ac.active
|
|
295
|
+
and (a.access_control = 'any_user' or exists (select 1 from meta.app_access aa where aa.app_id = a.id and aa.account_id = ac.id))
|
|
296
|
+
order by lower(ac.username) limit 200`, [appId])).rows.map((r) => r.username);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function tryPanel(s: Session, m: ModRow, h: Handler, tried?: TryResult, f?: Record<string, unknown>) {
|
|
300
|
+
const accounts = await tryAccounts(m.app_id);
|
|
301
|
+
const v = (k: string, d = '') => String(f?.[k] ?? d);
|
|
302
|
+
const qs = (h.params ?? []).filter((p) => p.in === 'query').map((p) => `${p.name}=`).join('&');
|
|
303
|
+
const bodyEx = h.method === 'GET' || h.method === 'DELETE' ? '' : JSON.stringify(Object.fromEntries((h.params ?? []).filter((p) => p.in === 'body').map((p) => [p.name, p.type === 'integer' || p.type === 'number' ? 0 : p.type === 'boolean' ? false : ''])), null, 2);
|
|
304
|
+
const result = tried
|
|
305
|
+
? html`<div class="u-mt1" role="status"><h3 class="small">Response</h3>
|
|
306
|
+
<p class="wrap-anywhere"><code>${tried.method} ${tried.url}</code> as <b>${tried.as || 'no token'}</b></p>
|
|
307
|
+
${tried.error ? html`<div class="alert alert-error">${tried.error}</div>` : html`<p><b>${tried.status}</b> <span class="muted">${tried.type ?? ''}</span></p>
|
|
308
|
+
${tried.body ? html`<pre class="source wrap-pre">${tried.body}</pre>` : ''}`}</div>`
|
|
309
|
+
: '';
|
|
310
|
+
const params = pathParams(h.path);
|
|
311
|
+
return region('Try it', html`<form method="post" action="${MODULES}/${m.id}/try">${csrf(s)}<input type="hidden" name="t" value="${h.path}"><input type="hidden" name="m" value="${h.method}">
|
|
312
|
+
<p class="muted u-mt0">Sends the request to the real endpoint: a PUT, POST or DELETE changes data. <em>As</em> an account issues a short-lived token for it, as App › REST API does; without one you see what a caller without a token gets.</p>
|
|
313
|
+
<div class="form-grid">
|
|
314
|
+
<div class="field"><label class="label" for="f_as">As</label><select id="f_as" name="as"><option value="">No token</option>${accounts.map((u) => html`<option${v('as') === u ? raw(' selected') : ''}>${u}</option>`)}</select></div>
|
|
315
|
+
${params.map((p) => html`<div class="field"><label class="label" for="f_p_${p}">:${p}</label><input id="f_p_${p}" name="p_${p}" value="${v(`p_${p}`)}" required></div>`)}
|
|
316
|
+
<div class="field"><label class="label" for="f_query">Query string</label><input id="f_query" name="query" value="${v('query', qs)}" placeholder="limit=10&offset=0"></div>
|
|
317
|
+
</div>
|
|
318
|
+
${h.method === 'GET' || h.method === 'DELETE' ? '' : html`<div class="field"><label class="label" for="f_body">JSON body</label><textarea id="f_body" name="body" class="code" rows="5" spellcheck="false" data-code="json">${v('body', bodyEx)}</textarea></div>`}
|
|
319
|
+
<div class="buttons"><button class="btn btn-hot">${icon('play')} Send ${h.method}</button></div></form>
|
|
320
|
+
${result}
|
|
321
|
+
<h3 class="small">cURL</h3><pre class="source wrap-pre">curl${h.method === 'GET' ? '' : ` -X ${h.method}`} "${baseUrl(m)}${h.path.replace(/:([a-z][a-z0-9_]*)/gi, (_, p) => `<${p}>`)}"${h.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"'}${bodyEx ? ` -H "Content-Type: application/json" -d '${bodyEx.replace(/\s+/g, ' ')}'` : ''}</pre>`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ------------------------------------------------------------------ changes
|
|
325
|
+
|
|
326
|
+
/** Change a module's definition in a transaction (row locked), after the Run only / lock check. */
|
|
327
|
+
async function change(m: ModRow, fn: (cur: { handlers: Handler[]; templates: Template[] }) => { handlers: Handler[]; templates: Template[] } | string[]): Promise<string[]> {
|
|
328
|
+
return owner.tx(async (c) => {
|
|
329
|
+
const cur = (await c.query('select handlers, templates from meta.rest_module where id = $1 for update', [m.id])).rows[0];
|
|
330
|
+
if (!cur) return ['The module no longer exists.'];
|
|
331
|
+
const next = fn({ handlers: Array.isArray(cur.handlers) ? cur.handlers : [], templates: Array.isArray(cur.templates) ? cur.templates : [] });
|
|
332
|
+
if (Array.isArray(next)) return next;
|
|
333
|
+
const problems = [...templateProblems(next.templates), ...handlerProblems(next.handlers)];
|
|
334
|
+
if (problems.length) return problems;
|
|
335
|
+
await c.query('update meta.rest_module set handlers = $2::jsonb, templates = $3::jsonb where id = $1', [m.id, JSON.stringify(next.handlers), JSON.stringify(next.templates)]);
|
|
336
|
+
return [];
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A handler from the form, or the problems with it. */
|
|
341
|
+
function readHandler(b: Record<string, unknown>, uri: string): { h: Handler; problems: string[] } {
|
|
342
|
+
const problems: string[] = [];
|
|
343
|
+
const str = (k: string) => String(b[k] ?? '').trim();
|
|
344
|
+
const h: Handler = { method: str('method') as Handler['method'], path: uri, type: str('type') as Handler['type'], source: String(b.source ?? '').replace(/\r\n/g, '\n').trim() };
|
|
345
|
+
const auth = str('auth') || 'token';
|
|
346
|
+
if (auth === 'public') h.auth = 'public';
|
|
347
|
+
else if (auth !== 'token') problems.push('Choose who may call it: a token of the application, or public.');
|
|
348
|
+
if (str('description')) h.description = str('description').slice(0, 500);
|
|
349
|
+
const roles = splitRoles(str('roles'));
|
|
350
|
+
if (roles.some((r) => r.length > 100)) problems.push('Role names are at most 100 characters.');
|
|
351
|
+
if (roles.length) h.roles = roles;
|
|
352
|
+
if (str('page_size')) {
|
|
353
|
+
const n = Number(str('page_size'));
|
|
354
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) problems.push('The page size is 1 to 500.');
|
|
355
|
+
else h.page_size = n;
|
|
356
|
+
}
|
|
357
|
+
if (str('status')) {
|
|
358
|
+
const n = Number(str('status'));
|
|
359
|
+
if (!Number.isInteger(n) || n < 200 || n > 299) problems.push('The status is 200 to 299.');
|
|
360
|
+
else h.status = n;
|
|
361
|
+
}
|
|
362
|
+
const p = parseParams(String(b.params ?? ''));
|
|
363
|
+
problems.push(...p.problems);
|
|
364
|
+
if (p.params.length) h.params = p.params;
|
|
365
|
+
return { h, problems };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function modOr404(req: Req, reply: FastifyReply, s: Session) {
|
|
369
|
+
const id = Number(req.params.id);
|
|
370
|
+
const m = Number.isInteger(id) && id > 0 && id < 2 ** 31 ? (await modules(s, id))[0] : undefined;
|
|
371
|
+
if (!m) reply.code(404).send('Not found');
|
|
372
|
+
else {
|
|
373
|
+
m.handlers = Array.isArray(m.handlers) ? m.handlers : [];
|
|
374
|
+
m.roles = m.roles ?? [];
|
|
375
|
+
}
|
|
376
|
+
return m;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const log = (req: Req, s: Session, m: Pick<ModRow, 'app_id' | 'name'>, detail: string) =>
|
|
380
|
+
logActivity({ appId: m.app_id, username: s.username, event: 'rest_module', ip: clientIp(req), detail: `${m.name}: ${detail}`.slice(0, 500) });
|
|
381
|
+
|
|
382
|
+
export async function restModuleRoutes(app: FastifyInstance) {
|
|
383
|
+
app.get(MODULES, async (req: Req, reply) => {
|
|
384
|
+
const s = await developer(req, reply);
|
|
385
|
+
if (!s) return;
|
|
386
|
+
return send(reply, s, await listPage(s));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
app.post(MODULES, async (req: Req, reply) => {
|
|
390
|
+
const s = await developer(req, reply);
|
|
391
|
+
if (!s) return;
|
|
392
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
393
|
+
const again = async (problems: string[]) => send(reply.code(422), s, await listPage(s, b, problems));
|
|
394
|
+
const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
|
|
395
|
+
if (!a) return again(['Choose an application of this workspace.']);
|
|
396
|
+
const name = String(b.name ?? '').trim();
|
|
397
|
+
const title = String(b.title ?? '').trim();
|
|
398
|
+
const problems: string[] = [];
|
|
399
|
+
if (!NAME.test(name)) problems.push('The base path starts with a lower case letter: letters, digits, _ and - (at most 40).');
|
|
400
|
+
if (!title || title.length > 200) problems.push('The title is 1 to 200 characters.');
|
|
401
|
+
const roles = splitRoles(b.roles);
|
|
402
|
+
const refused = await changeRefused(s, a.id);
|
|
403
|
+
if (refused) problems.push(refused);
|
|
404
|
+
if (problems.length) return again(problems);
|
|
405
|
+
try {
|
|
406
|
+
const r = await owner.one<{ id: number }>(
|
|
407
|
+
`insert into meta.rest_module (app_id, name, title, description, roles, handlers, templates) values ($1, $2, $3, $4, $5, '[]', '[]') returning id`,
|
|
408
|
+
[a.id, name, title, String(b.description ?? '').trim() || null, roles]);
|
|
409
|
+
await log(req, s, { app_id: a.id, name }, 'module created');
|
|
410
|
+
flash(s, `Module ${name} created at /a/${a.alias}/rest/${name}/. Add a template next.`);
|
|
411
|
+
return back(reply, s, `${MODULES}/${r!.id}`);
|
|
412
|
+
} catch (e) {
|
|
413
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
414
|
+
return again([`${a.name} already has a module ${name}.`]);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
app.get(`${MODULES}/:id`, async (req: Req, reply) => {
|
|
419
|
+
const s = await developer(req, reply);
|
|
420
|
+
if (!s) return;
|
|
421
|
+
const m = await modOr404(req, reply, s);
|
|
422
|
+
if (!m) return;
|
|
423
|
+
return send(reply, s, await modulePage(s, m));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
app.post(`${MODULES}/:id`, async (req: Req, reply) => {
|
|
427
|
+
const s = await developer(req, reply);
|
|
428
|
+
if (!s) return;
|
|
429
|
+
const m = await modOr404(req, reply, s);
|
|
430
|
+
if (!m) return;
|
|
431
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
432
|
+
const name = String(b.name ?? '').trim();
|
|
433
|
+
const title = String(b.title ?? '').trim();
|
|
434
|
+
const problems: string[] = [];
|
|
435
|
+
if (!NAME.test(name)) problems.push('The base path starts with a lower case letter: letters, digits, _ and - (at most 40).');
|
|
436
|
+
if (!title || title.length > 200) problems.push('The title is 1 to 200 characters.');
|
|
437
|
+
const refused = await changeRefused(s, m.app_id);
|
|
438
|
+
if (refused) problems.push(refused);
|
|
439
|
+
if (!problems.length) {
|
|
440
|
+
try {
|
|
441
|
+
await owner.query('update meta.rest_module set name = $2, title = $3, description = $4, roles = $5, enabled = $6 where id = $1',
|
|
442
|
+
[m.id, name, title, String(b.description ?? '').trim() || null, splitRoles(b.roles), b.enabled !== '0']);
|
|
443
|
+
} catch (e) {
|
|
444
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
445
|
+
problems.push(`${m.app_name} already has a module ${name}.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (problems.length) return send(reply.code(422), s, await modulePage(s, m, problems, b));
|
|
449
|
+
await log(req, s, { app_id: m.app_id, name }, `module saved (${b.enabled === '0' ? 'disabled' : 'enabled'}, roles: ${splitRoles(b.roles).join(', ') || 'none'})`);
|
|
450
|
+
flash(s, 'Saved.');
|
|
451
|
+
return back(reply, s, `${MODULES}/${m.id}`);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
app.post(`${MODULES}/:id/delete`, async (req: Req, reply) => {
|
|
455
|
+
const s = await developer(req, reply);
|
|
456
|
+
if (!s) return;
|
|
457
|
+
const m = await modOr404(req, reply, s);
|
|
458
|
+
if (!m) return;
|
|
459
|
+
const refused = await changeRefused(s, m.app_id);
|
|
460
|
+
if (refused) {
|
|
461
|
+
flash(s, refused, 'error');
|
|
462
|
+
return back(reply, s, `${MODULES}/${m.id}`);
|
|
463
|
+
}
|
|
464
|
+
await owner.query('delete from meta.rest_module where id = $1', [m.id]);
|
|
465
|
+
await log(req, s, m, 'module deleted');
|
|
466
|
+
flash(s, `Module ${m.name} deleted: its URLs answer 404.`);
|
|
467
|
+
return back(reply, s, MODULES);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
app.get(`${MODULES}/:id/template`, async (req: Req, reply) => {
|
|
471
|
+
const s = await developer(req, reply);
|
|
472
|
+
if (!s) return;
|
|
473
|
+
const m = await modOr404(req, reply, s);
|
|
474
|
+
if (!m) return;
|
|
475
|
+
const uri = String(req.query?.t ?? '');
|
|
476
|
+
if (!templatesOf(m).some((t) => t.uri === uri)) return reply.code(404).send('Not found');
|
|
477
|
+
return send(reply, s, await templatePage(s, m, uri));
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// add a template (no orig), or change one (orig: its URI; its handlers move along)
|
|
481
|
+
app.post(`${MODULES}/:id/template`, async (req: Req, reply) => {
|
|
482
|
+
const s = await developer(req, reply);
|
|
483
|
+
if (!s) return;
|
|
484
|
+
const m = await modOr404(req, reply, s);
|
|
485
|
+
if (!m) return;
|
|
486
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
487
|
+
const uri = String(b.uri ?? '').trim().replace(/^\/+|\/+$/g, '');
|
|
488
|
+
const orig = b.orig === undefined ? null : String(b.orig);
|
|
489
|
+
const description = String(b.description ?? '').trim().slice(0, 500);
|
|
490
|
+
const refused = await changeRefused(s, m.app_id);
|
|
491
|
+
const problems = refused ? [refused] : await change(m, (cur) => {
|
|
492
|
+
const list = templatesOf(cur);
|
|
493
|
+
if (orig === null && list.some((t) => t.uri === uri)) return [`The template ${uri} is there already.`];
|
|
494
|
+
if (orig !== null && !list.some((t) => t.uri === orig)) return ['The template no longer exists.'];
|
|
495
|
+
const next = list.filter((t) => t.uri !== orig).map((t) => ({ uri: t.uri, ...(t.description ? { description: t.description } : {}) }));
|
|
496
|
+
next.push({ uri, ...(description ? { description } : {}) });
|
|
497
|
+
return { templates: next.sort((x, y) => x.uri.localeCompare(y.uri)), handlers: cur.handlers.map((h) => (orig !== null && h.path === orig ? { ...h, path: uri } : h)) };
|
|
498
|
+
});
|
|
499
|
+
if (problems.length) {
|
|
500
|
+
if (orig !== null && templatesOf(m).some((t) => t.uri === orig)) return send(reply.code(422), s, await templatePage(s, m, orig, problems));
|
|
501
|
+
return send(reply.code(422), s, await modulePage(s, m, problems));
|
|
502
|
+
}
|
|
503
|
+
await log(req, s, m, orig === null ? `template ${uri} added` : `template ${orig} saved as ${uri}`);
|
|
504
|
+
flash(s, 'Saved.');
|
|
505
|
+
return back(reply, s, tplUrl(m, uri));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
app.post(`${MODULES}/:id/template/delete`, async (req: Req, reply) => {
|
|
509
|
+
const s = await developer(req, reply);
|
|
510
|
+
if (!s) return;
|
|
511
|
+
const m = await modOr404(req, reply, s);
|
|
512
|
+
if (!m) return;
|
|
513
|
+
const uri = String(req.body?.uri ?? '');
|
|
514
|
+
const refused = await changeRefused(s, m.app_id);
|
|
515
|
+
const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates.filter((t) => t?.uri !== uri), handlers: cur.handlers.filter((h) => h.path !== uri) }));
|
|
516
|
+
if (problems.length) {
|
|
517
|
+
flash(s, problems.join(' '), 'error');
|
|
518
|
+
return back(reply, s, `${MODULES}/${m.id}`);
|
|
519
|
+
}
|
|
520
|
+
await log(req, s, m, `template ${uri} deleted`);
|
|
521
|
+
flash(s, `Template ${uri || '/'} and its handlers deleted.`);
|
|
522
|
+
return back(reply, s, `${MODULES}/${m.id}`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
app.get(`${MODULES}/:id/handler`, async (req: Req, reply) => {
|
|
526
|
+
const s = await developer(req, reply);
|
|
527
|
+
if (!s) return;
|
|
528
|
+
const m = await modOr404(req, reply, s);
|
|
529
|
+
if (!m) return;
|
|
530
|
+
const uri = String(req.query?.t ?? '');
|
|
531
|
+
const method = req.query?.m ? String(req.query.m) : null;
|
|
532
|
+
if (!templatesOf(m).some((t) => t.uri === uri)) return reply.code(404).send('Not found');
|
|
533
|
+
if (method && !m.handlers.some((h) => h.path === uri && h.method === method)) return reply.code(404).send('Not found');
|
|
534
|
+
const fresh = METHODS.includes(String(req.query?.new ?? '')) ? String(req.query!.new) : 'GET';
|
|
535
|
+
return send(reply, s, await handlerPage(s, m, uri, method, method ? {} : { form: { method: fresh, type: fresh === 'GET' ? 'collection' : 'plpgsql', auth: 'token' } }));
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
app.post(`${MODULES}/:id/handler`, async (req: Req, reply) => {
|
|
539
|
+
const s = await developer(req, reply);
|
|
540
|
+
if (!s) return;
|
|
541
|
+
const m = await modOr404(req, reply, s);
|
|
542
|
+
if (!m) return;
|
|
543
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
544
|
+
const uri = String(b.t ?? '');
|
|
545
|
+
const orig = b.orig_m ? String(b.orig_m) : null;
|
|
546
|
+
if (!templatesOf(m).some((t) => t.uri === uri)) return reply.code(404).send('Not found');
|
|
547
|
+
const { h, problems } = readHandler(b, uri);
|
|
548
|
+
const refused = await changeRefused(s, m.app_id);
|
|
549
|
+
if (refused) problems.push(refused);
|
|
550
|
+
if (!problems.length)
|
|
551
|
+
problems.push(...await change(m, (cur) => {
|
|
552
|
+
if (orig !== null && !cur.handlers.some((x) => x.path === uri && x.method === orig)) return ['The handler no longer exists.'];
|
|
553
|
+
const at = orig === null ? -1 : cur.handlers.findIndex((x) => x.path === uri && x.method === orig);
|
|
554
|
+
const handlers = [...cur.handlers];
|
|
555
|
+
if (at >= 0) handlers[at] = h;
|
|
556
|
+
else handlers.push(h);
|
|
557
|
+
return { handlers, templates: cur.templates };
|
|
558
|
+
}));
|
|
559
|
+
if (problems.length) return send(reply.code(422), s, await handlerPage(s, m, uri, orig, { problems, form: b }));
|
|
560
|
+
await log(req, s, m, `handler ${h.method} ${uri} ${orig === null ? 'created' : 'saved'} (${h.type}, ${h.auth === 'public' ? 'public' : 'token'})`);
|
|
561
|
+
flash(s, 'Saved.');
|
|
562
|
+
return back(reply, s, handlerUrl(m, h));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
app.post(`${MODULES}/:id/handler/delete`, async (req: Req, reply) => {
|
|
566
|
+
const s = await developer(req, reply);
|
|
567
|
+
if (!s) return;
|
|
568
|
+
const m = await modOr404(req, reply, s);
|
|
569
|
+
if (!m) return;
|
|
570
|
+
const uri = String(req.body?.t ?? '');
|
|
571
|
+
const method = String(req.body?.m ?? '');
|
|
572
|
+
const refused = await changeRefused(s, m.app_id);
|
|
573
|
+
const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates, handlers: cur.handlers.filter((h) => !(h.path === uri && h.method === method)) }));
|
|
574
|
+
if (problems.length) {
|
|
575
|
+
flash(s, problems.join(' '), 'error');
|
|
576
|
+
return back(reply, s, `${MODULES}/${m.id}`);
|
|
577
|
+
}
|
|
578
|
+
await log(req, s, m, `handler ${method} ${uri} deleted`);
|
|
579
|
+
flash(s, `Handler ${method} ${uri || '/'} deleted.`);
|
|
580
|
+
return back(reply, s, templatesOf({ ...m, handlers: m.handlers.filter((h) => !(h.path === uri && h.method === method)) }).some((t) => t.uri === uri) ? tplUrl(m, uri) : `${MODULES}/${m.id}`);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Try it: the request goes through the real endpoint (so its checks all apply), the response is shown in the page
|
|
584
|
+
app.post(`${MODULES}/:id/try`, async (req: Req, reply) => {
|
|
585
|
+
const s = await developer(req, reply);
|
|
586
|
+
if (!s) return;
|
|
587
|
+
const m = await modOr404(req, reply, s);
|
|
588
|
+
if (!m) return;
|
|
589
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
590
|
+
const uri = String(b.t ?? '');
|
|
591
|
+
const h = m.handlers.find((x) => x.path === uri && x.method === String(b.m ?? ''));
|
|
592
|
+
if (!h) return reply.code(404).send('Not found');
|
|
593
|
+
const as = String(b.as ?? '');
|
|
594
|
+
const path = uri.split('/').map((seg) => (seg.startsWith(':') ? encodeURIComponent(String(b[`p_${seg.slice(1)}`] ?? '')) : seg)).join('/');
|
|
595
|
+
const query = String(b.query ?? '').trim().replace(/^\?/, '');
|
|
596
|
+
const url = `/a/${m.app_alias}/rest/${m.name}/${path}${query ? `?${new URLSearchParams(query)}` : ''}`;
|
|
597
|
+
const tried: TryResult = { method: h.method, url, as };
|
|
598
|
+
try {
|
|
599
|
+
const headers: Record<string, string> = {};
|
|
600
|
+
if (as) {
|
|
601
|
+
if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error('Choose an account with access to the application.');
|
|
602
|
+
headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1)).token}`;
|
|
603
|
+
}
|
|
604
|
+
const raw = h.method === 'GET' || h.method === 'DELETE' ? '' : String(b.body ?? '').trim();
|
|
605
|
+
if (raw) {
|
|
606
|
+
try {
|
|
607
|
+
JSON.parse(raw);
|
|
608
|
+
} catch {
|
|
609
|
+
throw new Error('The body is not valid JSON.');
|
|
610
|
+
}
|
|
611
|
+
headers['content-type'] = 'application/json';
|
|
612
|
+
}
|
|
613
|
+
const res = await req.server.inject({ method: h.method, url, headers, ...(raw ? { payload: raw } : {}) });
|
|
614
|
+
tried.status = res.statusCode;
|
|
615
|
+
tried.type = String(res.headers['content-type'] ?? '');
|
|
616
|
+
if (/json/.test(tried.type)) {
|
|
617
|
+
let text = res.body;
|
|
618
|
+
try {
|
|
619
|
+
text = JSON.stringify(JSON.parse(res.body), null, 2);
|
|
620
|
+
} catch { /* as sent */ }
|
|
621
|
+
tried.body = text.length > MAX_TRY_BODY ? `${text.slice(0, MAX_TRY_BODY)}\n… (${text.length} characters)` : text;
|
|
622
|
+
} else if (res.rawPayload.length) tried.body = `(${res.rawPayload.length} bytes of ${tried.type || 'content'})`;
|
|
623
|
+
await log(req, s, m, `tried ${h.method} ${uri} as ${as || 'no token'}: ${res.statusCode}`);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
tried.error = (e as Error).message;
|
|
626
|
+
}
|
|
627
|
+
return send(reply, s, await handlerPage(s, m, uri, h.method, { tried, tryForm: b }));
|
|
628
|
+
});
|
|
629
|
+
}
|