pgkiln 0.46.0 → 0.48.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/db/migrations/108_sql_history.sql +25 -0
- 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/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +178 -21
- package/docs/guide/08-security.md +19 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +46 -2
- package/docs/guide/13-rest-api.md +74 -9
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +293 -0
- package/public/worksheet.js +669 -0
- package/src/app.ts +4 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +167 -0
- package/src/builder/object-browser.ts +47 -26
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +28 -81
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +21 -15
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +592 -0
- package/src/builder/workspaces.ts +5 -3
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
package/src/app.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { reportSettingsRoutes } from './builder/report-settings.ts';
|
|
|
37
37
|
import { layoutRoutes } from './builder/layouts.ts';
|
|
38
38
|
import { dataLoadRoutes } from './builder/dataload.ts';
|
|
39
39
|
import { builderRoutes } from './builder/routes.ts';
|
|
40
|
+
import { dbUserRoutes } from './builder/db-users.ts';
|
|
40
41
|
import { usersRoutes } from './builder/users.ts';
|
|
41
42
|
import { codeEditorRoutes } from './builder/code-editor.ts';
|
|
42
43
|
import { sampleDataRoutes } from './builder/sampledata.ts';
|
|
@@ -59,6 +60,7 @@ import { staticFileRoutes } from './runtime/static-files.ts';
|
|
|
59
60
|
import { staticFileBuilderRoutes } from './builder/static-files.ts';
|
|
60
61
|
import { pluginRoutes } from './builder/plugins.ts';
|
|
61
62
|
import { restRoutes } from './runtime/rest.ts';
|
|
63
|
+
import { autoRestRoutes } from './runtime/autorest.ts';
|
|
62
64
|
import { oauthRoutes } from './oauth.ts';
|
|
63
65
|
import { MAX_UPLOAD_MB } from './runtime/files.ts';
|
|
64
66
|
import { runtimeRoutes } from './runtime/routes.ts';
|
|
@@ -183,6 +185,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
183
185
|
await app.register(pushRoutes);
|
|
184
186
|
await app.register(staticFileRoutes);
|
|
185
187
|
await app.register(restRoutes);
|
|
188
|
+
await app.register(autoRestRoutes); // (sprint 100) AutoREST: /a/:alias/autorest/
|
|
186
189
|
await app.register(builderRoutes);
|
|
187
190
|
await app.register(galleryRoutes);
|
|
188
191
|
await app.register(appGroupRoutes);
|
|
@@ -199,6 +202,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
199
202
|
await app.register(pluginRoutes);
|
|
200
203
|
await app.register(sqlRoutes);
|
|
201
204
|
await app.register(usersRoutes);
|
|
205
|
+
await app.register(dbUserRoutes);
|
|
202
206
|
await app.register(dataLoadRoutes);
|
|
203
207
|
await app.register(layoutRoutes);
|
|
204
208
|
await app.register(automationRoutes);
|
|
@@ -15,7 +15,7 @@ import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
|
15
15
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
16
16
|
// chooses for the builder (meta.builder_ai):
|
|
17
17
|
// - SQL Workshop → AI: SQL from a question (shown, never run: the developer
|
|
18
|
-
// may run it in SQL
|
|
18
|
+
// may run it in the SQL Worksheet), and an explanation of a query or an error;
|
|
19
19
|
// - SQL Workshop → AI → Describe tables: descriptions of tables and columns
|
|
20
20
|
// for the model (meta.ai_table_note, optionally also COMMENT ON), typed or
|
|
21
21
|
// drafted by AI from the names and types (never from the data) and
|
|
@@ -148,7 +148,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
|
|
|
148
148
|
${out.result ?? ''}
|
|
149
149
|
<div class="columns">
|
|
150
150
|
${region('SQL from a question', html`
|
|
151
|
-
<p class="muted u-mt0">The answer is shown, never run: check it, then run it in SQL
|
|
151
|
+
<p class="muted u-mt0">The answer is shown, never run: check it, then run it in the SQL Worksheet if you want.</p>
|
|
152
152
|
<form method="post" action="${BASE}/sql/ai/sql">${csrf(s)}
|
|
153
153
|
<div class="form-grid">${select('schema', 'Schema', out.schema ?? list[0] ?? '', list)}</div>
|
|
154
154
|
<div class="field"><label class="label" for="f_question">Question</label>
|
|
@@ -324,7 +324,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
324
324
|
<form method="post" action="${BASE}/sql">${csrf(s)}
|
|
325
325
|
<div class="field"><label class="label" for="f_ai_sql">SQL (not run)</label>
|
|
326
326
|
<textarea id="f_ai_sql" name="sql" class="code sql-editor" rows="10" spellcheck="false" data-code="plpgsql">${sql}</textarea></div>
|
|
327
|
-
<div class="buttons"><button class="btn">Run in SQL
|
|
327
|
+
<div class="buttons"><button class="btn">Run in the SQL Worksheet</button></div>
|
|
328
328
|
</form>`);
|
|
329
329
|
} catch (e) {
|
|
330
330
|
result = errorBox(e);
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { apiRoleProblem } 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 { AUTOREST_METHODS, describeColumns, keyColumns, MAX_PAGE, qualified, RELKINDS, type RestObject } from '../runtime/autorest.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
|
+
import { publicUrl } from '../sso.ts';
|
|
9
|
+
import { SYSTEM_SCHEMAS } from './object-browser.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, usableSchemas, workshopDb } from './workspaces.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 100, Database Actions DA-11 part 1) SQL Workshop › RESTful Services: AutoREST. A developer publishes a
|
|
15
|
+
// table or view of a schema the SQL Workshop may use through an application of the workspace (meta.rest_object,
|
|
16
|
+
// migration 113); src/runtime/autorest.ts serves it at /a/<alias>/autorest/<object>/. The application decides who
|
|
17
|
+
// may call it (its tokens and OAuth clients, App › REST API) and as which database role it runs: never the
|
|
18
|
+
// object's owner or a role that bypasses row level security. This page lists the published objects, enables and
|
|
19
|
+
// changes them, and shows the URLs, cURL and fetch examples and the OpenAPI link.
|
|
20
|
+
|
|
21
|
+
const PAGE = `${BASE}/sql/rest`;
|
|
22
|
+
const ALIAS = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
23
|
+
|
|
24
|
+
interface Rel {
|
|
25
|
+
schema: string;
|
|
26
|
+
name: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
}
|
|
29
|
+
interface WsApp {
|
|
30
|
+
id: number;
|
|
31
|
+
alias: string;
|
|
32
|
+
name: string;
|
|
33
|
+
db_role: string | null;
|
|
34
|
+
}
|
|
35
|
+
type Row = RestObject & { app_alias: string; app_name: string; db_role: string | null };
|
|
36
|
+
|
|
37
|
+
const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
|
|
38
|
+
const kindLabel = (k: string) => RELKINDS[k] ?? k;
|
|
39
|
+
const autoUrl = (alias: string, o?: string) => `${publicUrl()}/a/${alias}/autorest/${o ? `${o}/` : ''}`;
|
|
40
|
+
|
|
41
|
+
/** The applications of the developer's current workspace. */
|
|
42
|
+
async function workspaceApps(s: Session): Promise<WsApp[]> {
|
|
43
|
+
return (await owner.query<WsApp>(`select id, alias, name, db_role from meta.app a where meta.app_workspace(a.id) = $1 order by lower(name)`, [currentWorkspace(s)])).rows;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The published objects of the current workspace (one, with id). */
|
|
47
|
+
async function rows(s: Session, id?: number): Promise<Row[]> {
|
|
48
|
+
return (await owner.query<Row>(
|
|
49
|
+
`select o.*, a.alias as app_alias, a.name as app_name, a.db_role from meta.rest_object o join meta.app a on a.id = o.app_id
|
|
50
|
+
where meta.app_workspace(a.id) = $1 ${id !== undefined ? 'and o.id = $2' : ''} order by a.alias, o.alias`,
|
|
51
|
+
id !== undefined ? [currentWorkspace(s), id] : [currentWorkspace(s)],
|
|
52
|
+
)).rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Tables and views the SQL Workshop's connection may use. */
|
|
56
|
+
async function relations(s: Session): Promise<Rel[]> {
|
|
57
|
+
const db = await workshopDb(s);
|
|
58
|
+
const schemas = (await usableSchemas(db)).filter((x) => !SYSTEM_SCHEMAS.includes(x));
|
|
59
|
+
if (!schemas.length) return [];
|
|
60
|
+
return (await db.query<Rel>(
|
|
61
|
+
`select n.nspname as schema, c.relname as name, c.relkind::text as kind from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
62
|
+
where n.nspname = any($1) and c.relkind in ('r', 'p', 'v', 'm', 'f') and not c.relispartition
|
|
63
|
+
and not (n.nspname = 'public' and c.relname like 'pgkiln\\_%')
|
|
64
|
+
order by 1, 2 limit 5000`, [schemas])).rows;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A relation the workshop may use, by schema and name. */
|
|
68
|
+
async function findRel(s: Session, schema: unknown, name: unknown): Promise<Rel | undefined> {
|
|
69
|
+
if (typeof schema !== 'string' || typeof name !== 'string' || SYSTEM_SCHEMAS.includes(schema)) return undefined;
|
|
70
|
+
return (await relations(s)).find((r) => r.schema === schema && r.name === name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Why `role` may not serve the relation; null when it may. */
|
|
74
|
+
async function roleProblem(role: string | null, rel: Pick<Rel, 'schema' | 'name'>): Promise<string | null> {
|
|
75
|
+
if (!role) return 'The application has no database role. AutoREST runs as the application\'s role: set one in its settings.';
|
|
76
|
+
const problem = await apiRoleProblem(role);
|
|
77
|
+
if (problem) return `The application's database role: ${problem}`;
|
|
78
|
+
const r = await owner.one<{ owns: boolean }>(
|
|
79
|
+
`select pg_has_role($1, c.relowner, 'MEMBER') as owns from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $2 and c.relname = $3`,
|
|
80
|
+
[role, rel.schema, rel.name]);
|
|
81
|
+
if (r?.owns) return `"${role}" owns ${rel.schema}.${rel.name} (or is a member of its owner): grants and row level security would not apply. Publish it through an application whose role does not own it.`;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Input {
|
|
86
|
+
methods: string[];
|
|
87
|
+
auth: 'token' | 'public';
|
|
88
|
+
roles: string[];
|
|
89
|
+
page_size: number;
|
|
90
|
+
enabled: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The form's settings, or the problems with them. */
|
|
94
|
+
async function readInput(b: Record<string, unknown>, rel: Pick<Rel, 'schema' | 'name' | 'kind'>, role: string | null): Promise<{ input: Input; problems: string[] }> {
|
|
95
|
+
const problems: string[] = [];
|
|
96
|
+
const methods = AUTOREST_METHODS.filter((m) => many(b.methods).includes(m));
|
|
97
|
+
if (!methods.length) problems.push('Choose at least one method.');
|
|
98
|
+
const auth = b.auth === 'public' ? 'public' : b.auth === 'token' ? 'token' : null;
|
|
99
|
+
if (!auth) problems.push('Choose who may call it: a token of the application, or public.');
|
|
100
|
+
const size = Number(b.page_size ?? 25);
|
|
101
|
+
if (!Number.isInteger(size) || size < 1 || size > MAX_PAGE) problems.push(`The page size is 1 to ${MAX_PAGE}.`);
|
|
102
|
+
const cols = role ? await describeColumns(owner, rel.schema, rel.name, role) : [];
|
|
103
|
+
if (rel.kind === 'm' && methods.some((m) => m !== 'GET')) problems.push('A materialized view answers GET only.');
|
|
104
|
+
if (!keyColumns(cols).length && methods.some((m) => m === 'PUT' || m === 'DELETE')) problems.push('PUT and DELETE need a primary key; this object has none.');
|
|
105
|
+
const roles = splitRoles(String(b.roles ?? ''));
|
|
106
|
+
if (roles.some((r) => r.length > 100)) problems.push('Role names are at most 100 characters.');
|
|
107
|
+
return { input: { methods, auth: auth ?? 'token', roles, page_size: size, enabled: b.enabled === undefined ? true : b.enabled === '1' }, problems };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const methodChecks = (selected: string[]) =>
|
|
111
|
+
html`<fieldset class="prop-group"><legend>Methods</legend><div class="form-grid">${AUTOREST_METHODS.map((m) =>
|
|
112
|
+
html`<label class="check"><input type="checkbox" name="methods" value="${m}"${selected.includes(m) ? raw(' checked') : ''}> ${m} <small class="help">${
|
|
113
|
+
{ GET: 'rows and one row', POST: 'insert a row', PUT: 'change a row', DELETE: 'delete a row' }[m]}</small></label>`)}</div></fieldset>`;
|
|
114
|
+
|
|
115
|
+
const authChoice = (auth: string) =>
|
|
116
|
+
html`<fieldset class="prop-group"><legend>Who may call it</legend>
|
|
117
|
+
<label class="check"><input type="radio" name="auth" value="token"${auth !== 'public' ? raw(' checked') : ''}> A bearer token of the application <small class="help">(an account's token or an OAuth client, App › REST API)</small></label>
|
|
118
|
+
<label class="check"><input type="radio" name="auth" value="public"${auth === 'public' ? raw(' checked') : ''}> Public: anyone, without a token <small class="help">(runs as <code>nobody</code>; grants and row level security still apply)</small></label>
|
|
119
|
+
</fieldset>`;
|
|
120
|
+
|
|
121
|
+
const errorList = (problems: string[]) =>
|
|
122
|
+
problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : '';
|
|
123
|
+
|
|
124
|
+
/** The list and the Enable form. */
|
|
125
|
+
async function listPage(s: Session, q: Record<string, unknown>, problems: string[] = []) {
|
|
126
|
+
const [list, apps, rels] = await Promise.all([rows(s), workspaceApps(s), relations(s)]);
|
|
127
|
+
const usable = apps.filter((a) => a.db_role);
|
|
128
|
+
const wantedRel = typeof q.schema === 'string' && typeof q.name === 'string' ? `${q.schema}.${q.name}` : '';
|
|
129
|
+
const wantedRelKey = rels.find((r) => `${r.schema}.${r.name}` === wantedRel) ? JSON.stringify([q.schema, q.name]) : '';
|
|
130
|
+
const bySchema = new Map<string, Rel[]>();
|
|
131
|
+
for (const r of rels) bySchema.set(r.schema, [...(bySchema.get(r.schema) ?? []), r]);
|
|
132
|
+
const table = list.length
|
|
133
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Object</th><th>URL</th><th>Application</th><th>Methods</th><th>Access</th></tr></thead><tbody>
|
|
134
|
+
${list.map((o) => html`<tr${o.enabled ? '' : raw(' class="muted"')}>
|
|
135
|
+
<td data-label="Object"><a href="${PAGE}/${o.id}">${o.schema_name}.${o.object_name}</a>${o.enabled ? '' : html` <span class="tag">off</span>`}</td>
|
|
136
|
+
<td data-label="URL" class="wrap-anywhere"><code>/a/${o.app_alias}/autorest/${o.alias}/</code></td>
|
|
137
|
+
<td data-label="Application">${o.app_name}</td>
|
|
138
|
+
<td data-label="Methods">${o.methods.join(', ')}</td>
|
|
139
|
+
<td data-label="Access">${o.auth === 'public' ? html`<strong>public</strong>` : 'token'}${o.roles.length ? `, role ${o.roles.join(' or ')}` : ''}</td></tr>`)}
|
|
140
|
+
</tbody></table></div>`
|
|
141
|
+
: html`<p class="muted">No tables or views are published yet. Enable one below, or with <em>REST › Enable</em> in an object's ⋯ menu.</p>`;
|
|
142
|
+
const form = html`<form method="post" action="${PAGE}" id="enable">${csrf(s)}
|
|
143
|
+
${errorList(problems)}
|
|
144
|
+
<div class="form-grid">
|
|
145
|
+
<div class="field"><label class="label" for="f_object">Table or view</label>
|
|
146
|
+
<select id="f_object" name="object" required><option value="">- choose -</option>${[...bySchema].map(([schema, rs]) =>
|
|
147
|
+
html`<optgroup label="${schema}">${rs.map((r) => {
|
|
148
|
+
const v = JSON.stringify([r.schema, r.name]);
|
|
149
|
+
return html`<option value="${v}"${v === wantedRelKey ? raw(' selected') : ''}>${r.schema}.${r.name}${r.kind === 'r' || r.kind === 'p' ? '' : ` (${kindLabel(r.kind)})`}</option>`;
|
|
150
|
+
})}</optgroup>`)}</select></div>
|
|
151
|
+
<div class="field"><label class="label" for="f_app">Application</label>
|
|
152
|
+
<select id="f_app" name="app_id" required>${usable.map((a) => html`<option value="${a.id}"${String(q.app_id ?? '') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias}, runs as ${a.db_role})</option>`)}</select>
|
|
153
|
+
<small class="help">Its database role runs the statements, its tokens and roles decide who may call it.</small></div>
|
|
154
|
+
<div class="field"><label class="label" for="f_alias">URL name</label>
|
|
155
|
+
<input id="f_alias" name="alias" value="${String(q.alias ?? '')}" placeholder="the object's name">
|
|
156
|
+
<small class="help">/a/<app>/autorest/<b>name</b>/ — lower case letters, digits, _ and -</small></div>
|
|
157
|
+
<div class="field"><label class="label" for="f_roles">Roles</label>
|
|
158
|
+
<input id="f_roles" name="roles" value="${String(q.roles ?? '')}" placeholder="any caller">
|
|
159
|
+
<small class="help">Comma separated: the caller needs one of these application roles.</small></div>
|
|
160
|
+
<div class="field"><label class="label" for="f_page_size">Page size</label>
|
|
161
|
+
<input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${String(q.page_size ?? 25)}">
|
|
162
|
+
<small class="help">Rows per page without ?limit= (at most ${MAX_PAGE}).</small></div>
|
|
163
|
+
</div>
|
|
164
|
+
${methodChecks(many(q.methods).length ? many(q.methods) : ['GET'])}
|
|
165
|
+
${authChoice(String(q.auth ?? 'token'))}
|
|
166
|
+
<div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')} Enable</button></div>
|
|
167
|
+
${usable.length ? '' : html`<p class="muted">No application of this workspace has a database role to run as.</p>`}
|
|
168
|
+
</form>`;
|
|
169
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}
|
|
170
|
+
<p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL, use an application's REST modules (Shared Components).</p>
|
|
171
|
+
${region('Published objects', table)}
|
|
172
|
+
${region('Enable a table or view', form)}`;
|
|
173
|
+
return shell(s, 'RESTful Services', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services']], main, 'sql');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One published object: checks, endpoints, examples, settings. */
|
|
177
|
+
async function detailPage(s: Session, o: Row, problems: string[] = []) {
|
|
178
|
+
const rel = { schema: o.schema_name, name: o.object_name };
|
|
179
|
+
const info = await owner.one<{ kind: string; rls: boolean; force: boolean; policies: number; invoker: boolean; oid: number }>(
|
|
180
|
+
`select c.relkind::text as kind, c.relrowsecurity as rls, c.relforcerowsecurity as force, c.oid::int as oid,
|
|
181
|
+
(select count(*) from pg_policy p where p.polrelid = c.oid)::int as policies,
|
|
182
|
+
coalesce(c.reloptions && array['security_invoker=true', 'security_invoker=on', 'security_invoker=1'], false) as invoker
|
|
183
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]);
|
|
184
|
+
const role = o.db_role;
|
|
185
|
+
const problem = info ? await roleProblem(role, rel) : 'The object no longer exists: its endpoints answer 404.';
|
|
186
|
+
const cols = info && role ? await describeColumns(owner, o.schema_name, o.object_name, role) : [];
|
|
187
|
+
const key = keyColumns(cols);
|
|
188
|
+
const privs = info && role && !problem
|
|
189
|
+
? (await owner.one<Record<string, boolean>>(
|
|
190
|
+
`select has_table_privilege($1, $2::oid, 'SELECT') as "GET", has_table_privilege($1, $2::oid, 'INSERT') as "POST",
|
|
191
|
+
has_table_privilege($1, $2::oid, 'UPDATE') or has_any_column_privilege($1, $2::oid, 'UPDATE') as "PUT", has_table_privilege($1, $2::oid, 'DELETE') as "DELETE",
|
|
192
|
+
has_any_column_privilege($1, $2::oid, 'SELECT') as sel`, [role, info.oid]))!
|
|
193
|
+
: null;
|
|
194
|
+
const check = (ok: boolean | null, text: Raw | string) => html`<li>${ok === null ? 'ℹ' : ok ? '✓' : '✗'} ${text}</li>`;
|
|
195
|
+
const priv = { GET: 'select', POST: 'insert', PUT: 'update', DELETE: 'delete' } as Record<string, string>;
|
|
196
|
+
const checks = html`<ul class="checklist">
|
|
197
|
+
${check(!problem, problem ?? html`Runs as <code>${role}</code>: not the owner, no superuser, no row level security bypass`)}
|
|
198
|
+
${privs ? o.methods.map((m) => check(m === 'GET' ? privs.GET || privs.sel : privs[m], privs[m] || (m === 'GET' && privs.sel)
|
|
199
|
+
? html`${m}: <code>${role}</code> may ${priv[m]}`
|
|
200
|
+
: html`${m}: <code>${role}</code> may not ${priv[m]} — <code>grant ${priv[m]} on ${qualified(o)} to ${role};</code>`)) : ''}
|
|
201
|
+
${info && (info.kind === 'r' || info.kind === 'p') ? check(info.rls ? true : null, info.rls ? `Row level security is on (${info.policies} polic${info.policies === 1 ? 'y' : 'ies'})` : 'Row level security is off: callers see every row the role may select') : ''}
|
|
202
|
+
${info && info.kind === 'v' ? check(info.invoker, info.invoker ? 'The view is security_invoker: the tables\' row level security applies to the caller' : html`The view runs with its owner's rights, so the row level security of the tables beneath does not apply: <code>alter view ${qualified(o)} set (security_invoker = true);</code>`) : ''}
|
|
203
|
+
${info ? check(key.length ? true : null, key.length ? html`Primary key ${key.map((k) => k.name).join(', ')}: single rows at <code>…/${o.alias}/<${key.map((k) => k.name).join(',')}></code>` : 'No primary key: the collection only (GET and POST)') : ''}
|
|
204
|
+
${cols.some((c) => !c.readable) ? check(null, `Columns the role may not select are left out: ${cols.filter((c) => !c.readable).map((c) => c.name).join(', ')}`) : ''}
|
|
205
|
+
</ul>`;
|
|
206
|
+
const base = autoUrl(o.app_alias, o.alias);
|
|
207
|
+
const keyEx = key.map((k) => `<${k.name}>`).join(',');
|
|
208
|
+
const endpoints: [string, string, string][] = [];
|
|
209
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', base, `Rows, paged: ?limit= (1–${MAX_PAGE}, default ${o.page_size}) &offset= ; ?<column>=<value> ; ?q=<text> ; ?order=<column>.desc`]);
|
|
210
|
+
if (o.methods.includes('POST')) endpoints.push(['POST', base, 'A JSON object of column values: the new row (201)']);
|
|
211
|
+
if (key.length) {
|
|
212
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', `${base}${keyEx}`, 'One row (404 when there is none)']);
|
|
213
|
+
if (o.methods.includes('PUT')) endpoints.push(['PUT', `${base}${keyEx}`, 'Change the columns the JSON object names']);
|
|
214
|
+
if (o.methods.includes('DELETE')) endpoints.push(['DELETE', `${base}${keyEx}`, 'Delete the row (204)']);
|
|
215
|
+
}
|
|
216
|
+
const auth = o.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"';
|
|
217
|
+
const sample = cols.filter((c) => c.readable && c.pk === null).slice(0, 2);
|
|
218
|
+
const body = JSON.stringify(Object.fromEntries(sample.map((c) => [c.name, c.cat === 'N' ? 1 : c.cat === 'B' ? true : '…'])));
|
|
219
|
+
const textCol = cols.find((c) => c.readable && c.cat === 'S');
|
|
220
|
+
const curl = [
|
|
221
|
+
o.methods.includes('GET') ? `curl "${base}?limit=10${textCol ? '&q=a' : ''}"${auth}` : '',
|
|
222
|
+
o.methods.includes('GET') && key.length ? `curl ${base}1${auth}` : '',
|
|
223
|
+
o.methods.includes('POST') ? `curl -X POST ${base}${auth} -H "Content-Type: application/json" -d '${body}'` : '',
|
|
224
|
+
o.methods.includes('PUT') && key.length ? `curl -X PUT ${base}1${auth} -H "Content-Type: application/json" -d '${body}'` : '',
|
|
225
|
+
o.methods.includes('DELETE') && key.length ? `curl -X DELETE ${base}1${auth}` : '',
|
|
226
|
+
].filter(Boolean).join('\n');
|
|
227
|
+
const fetchEx = `const res = await fetch('${base}?limit=10'${o.auth === 'public' ? '' : `, {\n headers: { Authorization: \`Bearer \${token}\` },\n}`});\nconst { items, has_more } = await res.json();`;
|
|
228
|
+
const openapi = `${publicUrl()}/a/${o.app_alias}/autorest/openapi.json`;
|
|
229
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}
|
|
230
|
+
<h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? 'enabled' : 'disabled'}</span></h2>
|
|
231
|
+
${errorList(problems)}
|
|
232
|
+
${!o.enabled ? html`<div class="alert alert-info" role="status">Disabled: the endpoints answer 404.</div>` : ''}
|
|
233
|
+
<div class="columns">
|
|
234
|
+
${region('Checks', checks)}
|
|
235
|
+
${region('Endpoints', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>URL</th><th>Does</th></tr></thead><tbody>
|
|
236
|
+
${endpoints.map(([m, u, d]) => html`<tr><td data-label="Method"><code>${m}</code></td><td data-label="URL" class="wrap-anywhere"><code>${u}</code></td><td data-label="Does">${d}</td></tr>`)}</tbody></table></div>
|
|
237
|
+
<p class="wrap-anywhere">OpenAPI: <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a> <span class="muted small">(the public objects; send a token for all of them)</span></p>`)}
|
|
238
|
+
</div>
|
|
239
|
+
${region('Try it', html`${o.auth === 'public'
|
|
240
|
+
? html`<p class="muted u-mt0">Public: no token needed.</p>`
|
|
241
|
+
: html`<p class="muted u-mt0">Get <code>$TOKEN</code> from <a href="${BASE}/apps/${o.app_id}/api">${o.app_name} › REST API</a>: issue a token for an account, or use an OAuth client's access token.${o.roles.length ? ` The caller needs the role ${o.roles.join(' or ')}.` : ''} Without a valid token the endpoints answer 401; without the role or a grant, 403.</p>`}
|
|
242
|
+
<h3 class="small">cURL</h3><pre class="source wrap-pre">${curl}</pre>
|
|
243
|
+
<h3 class="small">JavaScript (fetch)</h3><pre class="source wrap-pre">${fetchEx}</pre>`)}
|
|
244
|
+
${region('Settings', html`<form method="post" action="${PAGE}/${o.id}">${csrf(s)}
|
|
245
|
+
<p class="muted u-mt0 wrap-anywhere">Published by <a href="${BASE}/apps/${o.app_id}">${o.app_name}</a> (runs as <code>${role ?? '—'}</code>) at <code>/a/${o.app_alias}/autorest/${o.alias}/</code>.</p>
|
|
246
|
+
<div class="form-grid">
|
|
247
|
+
<div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" value="${o.roles.join(', ')}" placeholder="any caller"><small class="help">Comma separated: the caller needs one of these application roles.</small></div>
|
|
248
|
+
<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="${MAX_PAGE}" value="${o.page_size}"></div>
|
|
249
|
+
<div class="field"><label class="label" for="f_enabled">Status</label><select id="f_enabled" name="enabled"><option value="1"${o.enabled ? raw(' selected') : ''}>Enabled</option><option value="0"${o.enabled ? '' : raw(' selected')}>Disabled (404)</option></select></div>
|
|
250
|
+
</div>
|
|
251
|
+
${methodChecks(o.methods)}
|
|
252
|
+
${authChoice(o.auth)}
|
|
253
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div>
|
|
254
|
+
</form>
|
|
255
|
+
<form method="post" action="${PAGE}/${o.id}/delete" class="u-mt1">${csrf(s)}
|
|
256
|
+
<button class="btn btn-danger">${icon('trash')} Disable REST (remove)</button> <span class="muted small">The endpoints answer 404 at once; the table itself is not changed.</span>
|
|
257
|
+
</form>`)}`;
|
|
258
|
+
return shell(s, `REST ${o.schema_name}.${o.object_name}`, [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', PAGE], [`${o.schema_name}.${o.object_name}`]], main, 'sql');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const parseObject = (v: unknown): [string, string] | null => {
|
|
262
|
+
try {
|
|
263
|
+
const x = JSON.parse(String(v ?? ''));
|
|
264
|
+
return Array.isArray(x) && x.length === 2 && x.every((y) => typeof y === 'string') ? [x[0], x[1]] : null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
async function rowOr404(req: Req, reply: FastifyReply, s: Session) {
|
|
271
|
+
const id = Number(req.params.id);
|
|
272
|
+
const o = Number.isInteger(id) && id > 0 ? (await rows(s, id))[0] : undefined;
|
|
273
|
+
if (!o) reply.code(404).send('Not found');
|
|
274
|
+
return o;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
278
|
+
app.get(PAGE, async (req: Req, reply) => {
|
|
279
|
+
const s = await developer(req, reply);
|
|
280
|
+
if (!s) return;
|
|
281
|
+
return send(reply, s, await listPage(s, req.query ?? {}));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// an object's ⋯ menu (REST › Enable / Disable): its page when it is published, else the Enable form for it
|
|
285
|
+
app.get(`${PAGE}/object`, async (req: Req, reply) => {
|
|
286
|
+
const s = await developer(req, reply);
|
|
287
|
+
if (!s) return;
|
|
288
|
+
const q = req.query ?? {};
|
|
289
|
+
const rel = await findRel(s, q.schema, q.name);
|
|
290
|
+
if (!rel) return reply.code(404).send('Not found');
|
|
291
|
+
const hit = (await rows(s)).find((o) => o.schema_name === rel.schema && o.object_name === rel.name);
|
|
292
|
+
return reply.redirect(hit ? `${PAGE}/${hit.id}` : `${PAGE}?${new URLSearchParams({ schema: rel.schema, name: rel.name })}#enable`);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.post(PAGE, async (req: Req, reply) => {
|
|
296
|
+
const s = await developer(req, reply);
|
|
297
|
+
if (!s) return;
|
|
298
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
299
|
+
const pick = parseObject(b.object);
|
|
300
|
+
const rel = pick ? await findRel(s, pick[0], pick[1]) : undefined;
|
|
301
|
+
const again = async (problems: string[]) =>
|
|
302
|
+
send(reply.code(200), s, await listPage(s, { ...b, ...(rel ? { schema: rel.schema, name: rel.name } : {}) }, problems));
|
|
303
|
+
if (!rel) return again(['Choose a table or view of a schema the SQL Workshop may use.']);
|
|
304
|
+
const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
|
|
305
|
+
if (!a) return again(['Choose an application of this workspace.']);
|
|
306
|
+
const alias = String(b.alias ?? '').trim() || rel.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 63);
|
|
307
|
+
const problems: string[] = [];
|
|
308
|
+
if (!ALIAS.test(alias)) problems.push('The URL name starts with a lower case letter: letters, digits, _ and - (at most 63).');
|
|
309
|
+
const rp = await roleProblem(a.db_role, rel);
|
|
310
|
+
if (rp) problems.push(rp);
|
|
311
|
+
const { input, problems: more } = await readInput(b, rel, a.db_role);
|
|
312
|
+
problems.push(...more);
|
|
313
|
+
if (problems.length) return again(problems);
|
|
314
|
+
try {
|
|
315
|
+
const r = await owner.one<{ id: number }>(
|
|
316
|
+
`insert into meta.rest_object (app_id, schema_name, object_name, alias, methods, auth, roles, page_size, created_by)
|
|
317
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`,
|
|
318
|
+
[a.id, rel.schema, rel.name, alias, input.methods, input.auth, input.roles, input.page_size, s.username]);
|
|
319
|
+
await logActivity({ appId: a.id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `enabled ${rel.schema}.${rel.name} as ${alias} (${input.methods.join(', ')}, ${input.auth})` });
|
|
320
|
+
flash(s, `${rel.schema}.${rel.name} is published at /a/${a.alias}/autorest/${alias}/.`);
|
|
321
|
+
return back(reply, s, `${PAGE}/${r!.id}`);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
const err = e as { code?: string; constraint?: string; message: string };
|
|
324
|
+
if (err.code !== '23505') throw e;
|
|
325
|
+
return again([/alias/.test(err.constraint ?? '') ? `${a.name} already publishes something as ${alias}: choose another URL name.` : `${a.name} already publishes ${rel.schema}.${rel.name}.`]);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
app.get(`${PAGE}/:id`, async (req: Req, reply) => {
|
|
330
|
+
const s = await developer(req, reply);
|
|
331
|
+
if (!s) return;
|
|
332
|
+
const o = await rowOr404(req, reply, s);
|
|
333
|
+
if (!o) return;
|
|
334
|
+
return send(reply, s, await detailPage(s, o));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.post(`${PAGE}/:id`, async (req: Req, reply) => {
|
|
338
|
+
const s = await developer(req, reply);
|
|
339
|
+
if (!s) return;
|
|
340
|
+
const o = await rowOr404(req, reply, s);
|
|
341
|
+
if (!o) return;
|
|
342
|
+
const kind = (await owner.one<{ k: string }>(`select c.relkind::text as k from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]))?.k ?? 'r';
|
|
343
|
+
const { input, problems } = await readInput((req.body ?? {}) as Record<string, unknown>, { schema: o.schema_name, name: o.object_name, kind }, o.db_role);
|
|
344
|
+
if (problems.length) return send(reply, s, await detailPage(s, o, problems));
|
|
345
|
+
await owner.query(`update meta.rest_object set methods = $2, auth = $3, roles = $4, page_size = $5, enabled = $6, updated_at = now() where id = $1`,
|
|
346
|
+
[o.id, input.methods, input.auth, input.roles, input.page_size, input.enabled]);
|
|
347
|
+
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `changed ${o.schema_name}.${o.object_name} (${input.methods.join(', ')}, ${input.auth}${input.enabled ? '' : ', disabled'})` });
|
|
348
|
+
flash(s, 'Saved.');
|
|
349
|
+
return back(reply, s, `${PAGE}/${o.id}`);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
app.post(`${PAGE}/:id/delete`, async (req: Req, reply) => {
|
|
353
|
+
const s = await developer(req, reply);
|
|
354
|
+
if (!s) return;
|
|
355
|
+
const o = await rowOr404(req, reply, s);
|
|
356
|
+
if (!o) return;
|
|
357
|
+
await owner.query('delete from meta.rest_object where id = $1', [o.id]);
|
|
358
|
+
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `disabled ${o.schema_name}.${o.object_name}` });
|
|
359
|
+
flash(s, `${o.schema_name}.${o.object_name} is no longer published.`);
|
|
360
|
+
return back(reply, s, PAGE);
|
|
361
|
+
});
|
|
362
|
+
}
|