pgkiln 0.40.0 → 0.41.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/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/docs/apex-feature-parity.md +12 -9
- package/docs/guide/03-builder.md +37 -1
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/12-development.md +18 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +44 -0
- package/public/app.js +126 -0
- package/public/builder.css +1 -1
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/mail.ts +39 -0
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +32 -6
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +14 -0
- package/src/i18n/cs.ts +14 -0
- package/src/i18n/da.ts +14 -0
- package/src/i18n/de.ts +14 -0
- package/src/i18n/el.ts +14 -0
- package/src/i18n/es.ts +14 -0
- package/src/i18n/fi.ts +14 -0
- package/src/i18n/fr.ts +14 -0
- package/src/i18n/he.ts +14 -0
- package/src/i18n/it.ts +14 -0
- package/src/i18n/ja.ts +14 -0
- package/src/i18n/ko.ts +14 -0
- package/src/i18n/nb.ts +14 -0
- package/src/i18n/pl.ts +14 -0
- package/src/i18n/pt.ts +14 -0
- package/src/i18n/ru.ts +14 -0
- package/src/i18n/sv.ts +14 -0
- package/src/i18n/tr.ts +14 -0
- package/src/i18n/uk.ts +14 -0
- package/src/i18n/zh.ts +14 -0
- package/src/i18n.ts +28 -0
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +296 -12
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/regions.ts +3 -0
- package/src/runtime/routes.ts +39 -0
- package/src/websources.ts +11 -3
- package/src/workspace-export.ts +240 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { owner } from './db.ts';
|
|
2
|
+
import { sourceProblems, type RestColumn, type RestParam } from './websources.ts';
|
|
3
|
+
|
|
4
|
+
// REST source catalogs (migration 095; APEX: Workspace Utilities → REST Source
|
|
5
|
+
// Catalogs): a workspace's descriptions of web services. A catalog has a base
|
|
6
|
+
// URL and services (endpoints); an application makes a REST data source from a
|
|
7
|
+
// service (a copy: meta.rest_source). Catalogs are entered by hand, read from an
|
|
8
|
+
// OpenAPI 3 or Swagger 2 document (JSON), or imported from a catalog file
|
|
9
|
+
// (format "pgkiln-rest-catalog/1") that another workspace exported.
|
|
10
|
+
//
|
|
11
|
+
// The OpenAPI reader takes what a REST data source can use: per operation the
|
|
12
|
+
// path and its parameters (path, query, header; the scalar properties of a JSON
|
|
13
|
+
// request body), and from the JSON response of 200/201 (or default) where the
|
|
14
|
+
// rows are and their columns. A collection with an item path (GET /pets and
|
|
15
|
+
// PUT/DELETE /pets/{petId}) gets key columns and write-back operations. What it
|
|
16
|
+
// can't use is listed in the notes, never silently dropped.
|
|
17
|
+
|
|
18
|
+
export interface CatalogService {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string | null;
|
|
21
|
+
path: string;
|
|
22
|
+
method: string;
|
|
23
|
+
params: RestParam[];
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
body: string | null;
|
|
26
|
+
row_selector: string | null;
|
|
27
|
+
columns: RestColumn[];
|
|
28
|
+
key_columns: string[];
|
|
29
|
+
operations: Record<string, Record<string, string>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Catalog {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string | null;
|
|
35
|
+
base_url: string;
|
|
36
|
+
source: 'manual' | 'openapi';
|
|
37
|
+
api_version: string | null;
|
|
38
|
+
services: CatalogService[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const CATALOG_FORMAT = 'pgkiln-rest-catalog/1';
|
|
42
|
+
const CATALOG_NAME = /^\S(.{0,58}\S)?$/;
|
|
43
|
+
const SERVICE_NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
|
|
44
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
45
|
+
const MAX_SERVICES = 500;
|
|
46
|
+
const MAX_COLUMNS = 200;
|
|
47
|
+
const MAX_PARAMS = 50;
|
|
48
|
+
const ROW_PROPERTIES = ['items', 'data', 'results', 'records', 'rows', 'content', 'value', 'values', 'list', 'entries', 'elements', 'objects'];
|
|
49
|
+
|
|
50
|
+
/** "https://api.example.com/v1/" → "https://api.example.com/v1" */
|
|
51
|
+
export const trimBase = (url: string) => url.trim().replace(/\/+$/, '');
|
|
52
|
+
|
|
53
|
+
/** A service's full URL: the catalog's base URL and the service's path. */
|
|
54
|
+
export const serviceUrl = (baseUrl: string, path: string) => `${trimBase(baseUrl)}${path}`;
|
|
55
|
+
|
|
56
|
+
/** Problems with a catalog's own fields (empty: fine). */
|
|
57
|
+
export function catalogProblems(c: { name?: unknown; base_url?: unknown; description?: unknown; api_version?: unknown }): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
if (typeof c.name !== 'string' || !CATALOG_NAME.test(c.name)) out.push('The name is 1 to 60 characters, without spaces at the start or end.');
|
|
60
|
+
if (typeof c.base_url !== 'string' || !/^https?:\/\/[^\s<>"{}]+$/i.test(c.base_url) || c.base_url.length > 2000) out.push('The base URL starts with http:// or https://, without spaces or {parameters}, e.g. https://api.example.com/v1.');
|
|
61
|
+
else if (c.base_url.endsWith('/')) out.push('The base URL ends without a slash: the services\' paths start with one.');
|
|
62
|
+
else {
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(c.base_url);
|
|
65
|
+
if (u.username || u.password) out.push('The base URL may not contain a user name or password: an application\'s web credential signs in.');
|
|
66
|
+
if (u.search || u.hash) out.push('The base URL has no query or fragment: they belong to a service\'s path.');
|
|
67
|
+
} catch {
|
|
68
|
+
out.push('The base URL is not valid.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (c.description != null && (typeof c.description !== 'string' || c.description.length > 2000)) out.push('The description is at most 2000 characters.');
|
|
72
|
+
if (c.api_version != null && (typeof c.api_version !== 'string' || c.api_version.length > 100)) out.push('The API version is at most 100 characters.');
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Problems with a service (as a REST data source on the catalog's base URL; empty: fine). */
|
|
77
|
+
export function serviceProblems(baseUrl: string, v: Partial<CatalogService>): string[] {
|
|
78
|
+
const at = `Service ${typeof v.name === 'string' ? v.name : '?'}:`;
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
if (typeof v.name !== 'string' || !SERVICE_NAME.test(v.name)) out.push(`${at} the name is upper case letters, digits and _, starting with a letter (at most 60), e.g. GET_PETS.`);
|
|
81
|
+
if (typeof v.path !== 'string' || !/^\/[^\s<>"]*$/.test(v.path) || v.path.length > 2000) out.push(`${at} the path starts with /, without spaces, e.g. /pets/{pet_id}.`);
|
|
82
|
+
if (!METHODS.includes(String(v.method))) out.push(`${at} the method is one of ${METHODS.join(', ')}.`);
|
|
83
|
+
if (v.description != null && (typeof v.description !== 'string' || v.description.length > 2000)) out.push(`${at} the description is at most 2000 characters.`);
|
|
84
|
+
if (typeof v.path === 'string' && /^\/[^\s<>"]*$/.test(v.path))
|
|
85
|
+
for (const p of sourceProblems({ url: serviceUrl(baseUrl, v.path), params: v.params ?? [], columns: v.columns ?? [], headers: v.headers ?? {}, row_selector: v.row_selector, key_columns: v.key_columns ?? [], operations: v.operations ?? {} }))
|
|
86
|
+
out.push(`${at} ${p}`);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every problem of a whole catalog (its fields, each service, duplicate names). */
|
|
91
|
+
export function wholeCatalogProblems(c: Catalog): string[] {
|
|
92
|
+
const out = catalogProblems(c);
|
|
93
|
+
if (!Array.isArray(c.services)) return [...out, 'The services are a list.'];
|
|
94
|
+
if (c.services.length > MAX_SERVICES) out.push(`A catalog has at most ${MAX_SERVICES} services.`);
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
for (const s of c.services) {
|
|
97
|
+
if (seen.has(s.name)) out.push(`Service ${s.name} is there twice.`);
|
|
98
|
+
seen.add(s.name);
|
|
99
|
+
// (enough to fix the first ones: a big broken document would list thousands)
|
|
100
|
+
if (out.length < 50) out.push(...serviceProblems(typeof c.base_url === 'string' ? c.base_url : 'https://x', s));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------- names
|
|
106
|
+
|
|
107
|
+
/** petId → pet_id, X-Request-ID → x_request_id: a valid parameter or column name. */
|
|
108
|
+
export function snake(s: string) {
|
|
109
|
+
let n = s
|
|
110
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
111
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
114
|
+
.replace(/_+/g, '_')
|
|
115
|
+
.replace(/^_+|_+$/g, '');
|
|
116
|
+
if (!n) n = 'x';
|
|
117
|
+
if (/^\d/.test(n)) n = `_${n}`;
|
|
118
|
+
return n.slice(0, 63);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** listPets → LIST_PETS; a service name (upper case, starts with a letter, at most 60). */
|
|
122
|
+
export function serviceName(s: string) {
|
|
123
|
+
let n = snake(s).toUpperCase().replace(/^_+/, '');
|
|
124
|
+
if (!/^[A-Z]/.test(n)) n = `S_${n}`;
|
|
125
|
+
return n.slice(0, 60).replace(/_+$/, '') || 'SERVICE';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function unique(name: string, taken: Set<string>, max: number) {
|
|
129
|
+
let n = name;
|
|
130
|
+
for (let i = 2; taken.has(n); i++) n = `${name.slice(0, max - String(i).length - 1)}_${i}`;
|
|
131
|
+
taken.add(n);
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------- OpenAPI
|
|
136
|
+
|
|
137
|
+
type Json = Record<string, any>;
|
|
138
|
+
const isObj = (v: unknown): v is Json => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
139
|
+
|
|
140
|
+
class Reader {
|
|
141
|
+
readonly notes: string[] = [];
|
|
142
|
+
constructor(readonly doc: Json) {}
|
|
143
|
+
|
|
144
|
+
note(n: string) {
|
|
145
|
+
if (this.notes.length < 200 && !this.notes.includes(n)) this.notes.push(n);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Follow local $refs (#/components/…, #/definitions/…); external ones are noted and dropped. */
|
|
149
|
+
deref(v: unknown, depth = 0): Json | null {
|
|
150
|
+
if (!isObj(v)) return null;
|
|
151
|
+
if (typeof v.$ref !== 'string') return v;
|
|
152
|
+
if (depth > 30) {
|
|
153
|
+
this.note(`A $ref chain is too deep (or circular): ${v.$ref}.`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (!v.$ref.startsWith('#/')) {
|
|
157
|
+
this.note(`External references are not read: ${v.$ref}.`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
let cur: unknown = this.doc;
|
|
161
|
+
for (const part of v.$ref.slice(2).split('/')) {
|
|
162
|
+
const key = decodeURIComponent(part).replace(/~1/g, '/').replace(/~0/g, '~');
|
|
163
|
+
cur = isObj(cur) || Array.isArray(cur) ? (cur as Json)[key] : undefined;
|
|
164
|
+
}
|
|
165
|
+
if (cur === undefined) {
|
|
166
|
+
this.note(`A $ref points nowhere: ${v.$ref}.`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return this.deref(cur, depth + 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A schema with allOf merged (properties and required), for reading properties. */
|
|
173
|
+
schema(v: unknown, depth = 0): Json | null {
|
|
174
|
+
const s = this.deref(v);
|
|
175
|
+
if (!s || depth > 10) return s;
|
|
176
|
+
if (Array.isArray(s.allOf)) {
|
|
177
|
+
const merged: Json = { ...s, properties: { ...(s.properties ?? {}) }, required: [...(s.required ?? [])] };
|
|
178
|
+
delete merged.allOf;
|
|
179
|
+
for (const part of s.allOf) {
|
|
180
|
+
const p = this.schema(part, depth + 1);
|
|
181
|
+
if (!p) continue;
|
|
182
|
+
Object.assign(merged.properties, p.properties ?? {});
|
|
183
|
+
merged.required.push(...(Array.isArray(p.required) ? p.required : []));
|
|
184
|
+
if (!merged.type && p.type) merged.type = p.type;
|
|
185
|
+
if (!merged.items && p.items) merged.items = p.items;
|
|
186
|
+
}
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
189
|
+
// oneOf / anyOf: the first alternative is a fair guess for the columns
|
|
190
|
+
for (const k of ['oneOf', 'anyOf'])
|
|
191
|
+
if (Array.isArray(s[k]) && s[k].length && !s.properties && !s.items) return this.schema(s[k][0], depth + 1);
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
typeOf(s: Json | null): string | undefined {
|
|
196
|
+
if (!s) return undefined;
|
|
197
|
+
const t = Array.isArray(s.type) ? s.type.find((x: unknown) => x !== 'null') : s.type;
|
|
198
|
+
if (t) return t;
|
|
199
|
+
if (s.properties) return 'object';
|
|
200
|
+
if (s.items) return 'array';
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
columnType(prop: unknown): RestColumn['type'] {
|
|
205
|
+
const s = this.schema(prop);
|
|
206
|
+
const t = this.typeOf(s);
|
|
207
|
+
if (t === 'integer') return 'integer';
|
|
208
|
+
if (t === 'number') return 'number';
|
|
209
|
+
if (t === 'boolean') return 'boolean';
|
|
210
|
+
if (t === 'string') return s?.format === 'date' ? 'date' : s?.format === 'date-time' ? 'timestamp' : 'text';
|
|
211
|
+
if (t === 'object' || t === 'array') return 'json';
|
|
212
|
+
return 'text';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The columns of a row schema: its properties, in order (at most MAX_COLUMNS). */
|
|
216
|
+
columns(row: Json | null): RestColumn[] {
|
|
217
|
+
const props = isObj(row?.properties) ? Object.entries(row!.properties as Json) : [];
|
|
218
|
+
const taken = new Set<string>();
|
|
219
|
+
const out: RestColumn[] = [];
|
|
220
|
+
for (const [k, v] of props) {
|
|
221
|
+
if (out.length >= MAX_COLUMNS) {
|
|
222
|
+
this.note(`Only the first ${MAX_COLUMNS} properties became columns.`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
const name = unique(snake(k), taken, 63);
|
|
226
|
+
out.push({ name, path: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `$[${JSON.stringify(k)}]`, type: this.columnType(v) });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Where the rows are in a response schema, and the schema of one row. */
|
|
232
|
+
rows(schema: unknown): { row_selector: string | null; row: Json | null } {
|
|
233
|
+
const s = this.schema(schema);
|
|
234
|
+
if (!s) return { row_selector: null, row: null };
|
|
235
|
+
if (this.typeOf(s) === 'array') return { row_selector: null, row: this.schema(s.items) };
|
|
236
|
+
const props = isObj(s.properties) ? (s.properties as Json) : {};
|
|
237
|
+
const arrays = Object.keys(props).filter((k) => {
|
|
238
|
+
const p = this.schema(props[k]);
|
|
239
|
+
return this.typeOf(p) === 'array' && this.typeOf(this.schema(p?.items)) === 'object';
|
|
240
|
+
});
|
|
241
|
+
const pick = arrays.find((k) => ROW_PROPERTIES.includes(k.toLowerCase())) ?? (arrays.length === 1 ? arrays[0] : undefined);
|
|
242
|
+
if (pick) return { row_selector: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pick) ? pick : `$[${JSON.stringify(pick)}]`, row: this.schema(this.schema(props[pick])?.items) };
|
|
243
|
+
return { row_selector: null, row: s };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The JSON schema of an operation's success response (OpenAPI 3: content; Swagger 2: schema). */
|
|
247
|
+
responseSchema(op: Json): unknown {
|
|
248
|
+
const responses = isObj(op.responses) ? op.responses : {};
|
|
249
|
+
const code = ['200', '201', '2XX', '2xx', 'default'].find((c) => responses[c] !== undefined) ?? Object.keys(responses).find((c) => /^2\d\d$/.test(c));
|
|
250
|
+
if (!code) return undefined;
|
|
251
|
+
const r = this.deref(responses[code]);
|
|
252
|
+
if (!r) return undefined;
|
|
253
|
+
if (r.schema) return r.schema;
|
|
254
|
+
return jsonContent(r.content)?.schema;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The JSON media type of a content map (application/json, application/problem+json, or */* as a last resort).
|
|
259
|
+
function jsonContent(content: unknown): Json | null {
|
|
260
|
+
if (!isObj(content)) return null;
|
|
261
|
+
const key = Object.keys(content).find((k) => /^application\/(?:[\w.+-]*\+)?json\b/i.test(k)) ?? Object.keys(content).find((k) => k === '*/*');
|
|
262
|
+
return key && isObj(content[key]) ? content[key] : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const scalar = (v: unknown) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? String(v) : undefined);
|
|
266
|
+
|
|
267
|
+
/** The base URL of an OpenAPI 3 or Swagger 2 document (relative ones against `from`). */
|
|
268
|
+
function documentBase(doc: Json, from: string | undefined, notes: string[]): string | null {
|
|
269
|
+
let url: string | null = null;
|
|
270
|
+
if (Array.isArray(doc.servers) && isObj(doc.servers[0]) && typeof doc.servers[0].url === 'string') {
|
|
271
|
+
const server = doc.servers[0];
|
|
272
|
+
url = server.url.replace(/\{([^}]+)\}/g, (m: string, name: string) => {
|
|
273
|
+
const v = isObj(server.variables?.[name]) ? scalar(server.variables[name].default) : undefined;
|
|
274
|
+
if (v === undefined) notes.push(`The server variable {${name}} has no default: enter the base URL.`);
|
|
275
|
+
return v ?? m;
|
|
276
|
+
});
|
|
277
|
+
if (doc.servers.length > 1) notes.push(`The document lists ${doc.servers.length} servers: the first one is the base URL (${server.url}).`);
|
|
278
|
+
} else if (typeof doc.host === 'string') {
|
|
279
|
+
const scheme = Array.isArray(doc.schemes) ? (doc.schemes.includes('https') ? 'https' : doc.schemes[0]) : 'https';
|
|
280
|
+
url = `${scheme}://${doc.host}${typeof doc.basePath === 'string' ? doc.basePath : ''}`;
|
|
281
|
+
} else if (typeof doc.basePath === 'string') url = doc.basePath;
|
|
282
|
+
if (url && /\{/.test(url)) return null;
|
|
283
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
284
|
+
if (!from) return null;
|
|
285
|
+
try {
|
|
286
|
+
url = new URL(url, from).href;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return url ? trimBase(url) : from ? trimBase(new URL(from).origin) : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A catalog from an OpenAPI 3.x or Swagger 2.0 document (parsed JSON).
|
|
296
|
+
* `baseUrl` overrides the document's server; `from` is where the document was
|
|
297
|
+
* read from (a relative server URL is resolved against it).
|
|
298
|
+
*/
|
|
299
|
+
export function fromOpenApi(doc: unknown, opts: { baseUrl?: string; from?: string; name?: string } = {}): { catalog: Catalog; notes: string[] } {
|
|
300
|
+
if (!isObj(doc) || !(typeof doc.openapi === 'string' || doc.swagger === '2.0')) throw new Error('This is not an OpenAPI 3 or Swagger 2.0 document (no "openapi" or "swagger" field).');
|
|
301
|
+
if (typeof doc.openapi === 'string' && !/^3\./.test(doc.openapi)) throw new Error(`OpenAPI ${doc.openapi} is not supported: version 3.x or Swagger 2.0.`);
|
|
302
|
+
const r = new Reader(doc);
|
|
303
|
+
const baseNotes: string[] = [];
|
|
304
|
+
const base = opts.baseUrl?.trim() ? trimBase(opts.baseUrl) : documentBase(doc, opts.from, baseNotes);
|
|
305
|
+
for (const n of baseNotes) r.note(n);
|
|
306
|
+
if (!base) throw new Error('The document has no absolute server URL: enter the base URL of the service.');
|
|
307
|
+
const info = isObj(doc.info) ? doc.info : {};
|
|
308
|
+
const taken = new Set<string>();
|
|
309
|
+
const services: CatalogService[] = [];
|
|
310
|
+
const paths = isObj(doc.paths) ? doc.paths : {};
|
|
311
|
+
const swagger2 = doc.swagger === '2.0';
|
|
312
|
+
|
|
313
|
+
for (const [rawPath, item0] of Object.entries(paths)) {
|
|
314
|
+
const item = r.deref(item0);
|
|
315
|
+
if (!item || !rawPath.startsWith('/')) continue;
|
|
316
|
+
for (const m of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
|
|
317
|
+
const op = r.deref(item[m]);
|
|
318
|
+
if (!op) continue;
|
|
319
|
+
if (!METHODS.includes(m.toUpperCase())) {
|
|
320
|
+
r.note(`${m.toUpperCase()} ${rawPath}: only GET, POST, PUT, PATCH and DELETE become services.`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (services.length >= MAX_SERVICES) {
|
|
324
|
+
r.note(`Only the first ${MAX_SERVICES} operations became services.`);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const where = `${m.toUpperCase()} ${rawPath}`;
|
|
328
|
+
// parameters: the path's, overridden by the operation's (by name and location)
|
|
329
|
+
const byKey = new Map<string, Json>();
|
|
330
|
+
for (const p0 of [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])]) {
|
|
331
|
+
const p = r.deref(p0);
|
|
332
|
+
if (p && typeof p.name === 'string' && typeof p.in === 'string') byKey.set(`${p.in}:${p.name}`, p);
|
|
333
|
+
}
|
|
334
|
+
const params: RestParam[] = [];
|
|
335
|
+
const names = new Set<string>();
|
|
336
|
+
let path = rawPath;
|
|
337
|
+
let body: Json | null = null;
|
|
338
|
+
for (const p of byKey.values()) {
|
|
339
|
+
if (params.length >= MAX_PARAMS) {
|
|
340
|
+
r.note(`${where}: only the first ${MAX_PARAMS} parameters were kept.`);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
if (p.in === 'body') {
|
|
344
|
+
body = r.schema(p.schema);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!['path', 'query', 'header'].includes(p.in)) {
|
|
348
|
+
r.note(`${where}: ${p.in} parameter ${p.name} is not supported (path, query, header and a JSON body are).`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (p.in === 'header' && ['authorization', 'proxy-authorization', 'cookie', 'host', 'content-length', 'transfer-encoding', 'connection', 'accept', 'content-type'].includes(p.name.toLowerCase())) {
|
|
352
|
+
if (['authorization', 'proxy-authorization', 'cookie'].includes(p.name.toLowerCase())) r.note(`${where}: the ${p.name} header is left out: an application's web credential signs in.`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const name = unique(snake(p.name), names, 63);
|
|
356
|
+
const param: RestParam = { name, in: p.in };
|
|
357
|
+
const sch = r.schema(p.schema) ?? p;
|
|
358
|
+
const def = scalar(sch.default) ?? scalar(p.example) ?? (Array.isArray(sch.enum) && p.required ? scalar(sch.enum[0]) : undefined);
|
|
359
|
+
if (def !== undefined) param.default = def;
|
|
360
|
+
if (p.required || p.in === 'path') param.required = true;
|
|
361
|
+
if (p.in === 'path') path = path.split(`{${p.name}}`).join(`{${name}}`);
|
|
362
|
+
else if (name !== p.name) param.key = p.name;
|
|
363
|
+
params.push(param);
|
|
364
|
+
}
|
|
365
|
+
// a path parameter the document forgot to declare
|
|
366
|
+
for (const mm of path.matchAll(/\{([^}]+)\}/g))
|
|
367
|
+
if (!params.some((p) => p.in === 'path' && p.name === mm[1])) {
|
|
368
|
+
const name = unique(snake(mm[1]), names, 63);
|
|
369
|
+
path = path.split(`{${mm[1]}}`).join(`{${name}}`);
|
|
370
|
+
params.push({ name, in: 'path', required: true });
|
|
371
|
+
}
|
|
372
|
+
// a JSON request body: its scalar properties become body parameters
|
|
373
|
+
if (!swagger2 && op.requestBody) {
|
|
374
|
+
const rb = r.deref(op.requestBody);
|
|
375
|
+
const content = rb ? jsonContent(rb.content) : null;
|
|
376
|
+
if (rb && !content) r.note(`${where}: a request body that isn't JSON is not supported.`);
|
|
377
|
+
body = content ? r.schema(content.schema) : body;
|
|
378
|
+
}
|
|
379
|
+
if (body) {
|
|
380
|
+
const req: string[] = Array.isArray(body.required) ? body.required : [];
|
|
381
|
+
const props = isObj(body.properties) ? Object.entries(body.properties as Json) : [];
|
|
382
|
+
let skipped = 0;
|
|
383
|
+
for (const [k, v] of props) {
|
|
384
|
+
const t = r.columnType(v);
|
|
385
|
+
if (t === 'json' || params.length >= MAX_PARAMS) {
|
|
386
|
+
skipped++;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const name = unique(snake(k), names, 63);
|
|
390
|
+
const param: RestParam = { name, in: 'body' };
|
|
391
|
+
if (name !== k) param.key = k;
|
|
392
|
+
if (req.includes(k)) param.required = true;
|
|
393
|
+
params.push(param);
|
|
394
|
+
}
|
|
395
|
+
if (skipped) r.note(`${where}: ${skipped} nested request body propert${skipped > 1 ? 'ies were' : 'y was'} left out (write a body template for them).`);
|
|
396
|
+
if (!props.length && r.typeOf(body) === 'array') r.note(`${where}: the request body is a list: write a body template.`);
|
|
397
|
+
}
|
|
398
|
+
const { row_selector, row } = r.rows(r.responseSchema(op));
|
|
399
|
+
const columns = r.columns(row);
|
|
400
|
+
if (m === 'get' && !columns.length) r.note(`${where}: no JSON response schema with properties, so no columns: use "Test" on the data source to get them.`);
|
|
401
|
+
const id = typeof op.operationId === 'string' && op.operationId.trim() ? op.operationId : `${m}_${rawPath.replace(/\{([^}]+)\}/g, 'by_$1')}`;
|
|
402
|
+
const description = [op.summary, op.description].find((d) => typeof d === 'string' && d.trim());
|
|
403
|
+
services.push({
|
|
404
|
+
name: unique(serviceName(id), taken, 60),
|
|
405
|
+
description: description ? String(description).trim().slice(0, 2000) : null,
|
|
406
|
+
path,
|
|
407
|
+
method: m.toUpperCase(),
|
|
408
|
+
params,
|
|
409
|
+
headers: {},
|
|
410
|
+
body: null,
|
|
411
|
+
row_selector,
|
|
412
|
+
columns,
|
|
413
|
+
key_columns: [],
|
|
414
|
+
operations: {},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!services.length) r.note('The document has no operations that can become services.');
|
|
419
|
+
linkOperations(services);
|
|
420
|
+
const title = typeof info.title === 'string' && info.title.trim() ? info.title.trim() : 'REST service';
|
|
421
|
+
const name = (opts.name?.trim() || title).slice(0, 60).trim();
|
|
422
|
+
return {
|
|
423
|
+
catalog: {
|
|
424
|
+
name,
|
|
425
|
+
description: typeof info.description === 'string' ? info.description.trim().slice(0, 2000) || null : null,
|
|
426
|
+
base_url: base,
|
|
427
|
+
source: 'openapi',
|
|
428
|
+
api_version: typeof info.version === 'string' || typeof info.version === 'number' ? String(info.version).slice(0, 100) : null,
|
|
429
|
+
services,
|
|
430
|
+
},
|
|
431
|
+
notes: r.notes,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A collection GET (/pets) whose item path (/pets/{pet_id}) has GET, PUT/PATCH or
|
|
437
|
+
* DELETE, and the collection a POST: key columns and write-back operations, when
|
|
438
|
+
* the item's parameter names a column (pet_id, or else id).
|
|
439
|
+
*/
|
|
440
|
+
function linkOperations(services: CatalogService[]) {
|
|
441
|
+
for (const list of services) {
|
|
442
|
+
if (list.method !== 'GET' || !list.columns.length || /\{[^}]+\}$/.test(list.path) || list.path.includes('?')) continue;
|
|
443
|
+
const item = new RegExp(`^${list.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\{[^}]+\}/g, '\\{[^}]+\\}')}/\\{([^}]+)\\}$`);
|
|
444
|
+
const cols = list.columns.map((c) => c.name);
|
|
445
|
+
const ops: Record<string, Record<string, string>> = {};
|
|
446
|
+
let key: string | null = null;
|
|
447
|
+
for (const s of services) {
|
|
448
|
+
const m = item.exec(s.path);
|
|
449
|
+
if (!m) continue;
|
|
450
|
+
const col = cols.includes(m[1]) ? m[1] : cols.includes('id') ? 'id' : null;
|
|
451
|
+
if (!col || (key && key !== col)) continue;
|
|
452
|
+
const op = s.method === 'GET' ? 'fetch' : s.method === 'DELETE' ? 'delete' : s.method === 'PUT' || s.method === 'PATCH' ? 'update' : null;
|
|
453
|
+
if (!op || (op === 'update' && ops.update?.method === 'PUT')) continue;
|
|
454
|
+
key = col;
|
|
455
|
+
ops[op] = { method: s.method, path: `/{${col}}` };
|
|
456
|
+
}
|
|
457
|
+
if (!key) continue;
|
|
458
|
+
if (services.some((s) => s.method === 'POST' && s.path === list.path)) ops.insert = { method: 'POST' };
|
|
459
|
+
list.key_columns = [key];
|
|
460
|
+
list.operations = ops;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------- catalog files
|
|
465
|
+
|
|
466
|
+
/** A catalog as a file (another workspace or installation imports it). */
|
|
467
|
+
export function catalogDocument(c: Catalog) {
|
|
468
|
+
return {
|
|
469
|
+
format: CATALOG_FORMAT,
|
|
470
|
+
name: c.name,
|
|
471
|
+
description: c.description,
|
|
472
|
+
base_url: c.base_url,
|
|
473
|
+
source: c.source,
|
|
474
|
+
api_version: c.api_version,
|
|
475
|
+
services: c.services.map((s) => ({
|
|
476
|
+
name: s.name, description: s.description, path: s.path, method: s.method, params: s.params, headers: s.headers,
|
|
477
|
+
body: s.body, row_selector: s.row_selector, columns: s.columns, key_columns: s.key_columns, operations: s.operations,
|
|
478
|
+
})),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A catalog file's catalog (checked by the caller with wholeCatalogProblems). */
|
|
483
|
+
export function fromCatalogDocument(doc: Json): Catalog {
|
|
484
|
+
if (doc.format !== CATALOG_FORMAT) throw new Error(`Not a REST source catalog file (format ${CATALOG_FORMAT}).`);
|
|
485
|
+
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null);
|
|
486
|
+
return {
|
|
487
|
+
name: String(doc.name ?? ''),
|
|
488
|
+
description: str(doc.description),
|
|
489
|
+
base_url: String(doc.base_url ?? ''),
|
|
490
|
+
source: doc.source === 'openapi' ? 'openapi' : 'manual',
|
|
491
|
+
api_version: str(doc.api_version),
|
|
492
|
+
services: (Array.isArray(doc.services) ? doc.services : []).map((s: Json) => ({
|
|
493
|
+
name: String(s?.name ?? ''),
|
|
494
|
+
description: str(s?.description),
|
|
495
|
+
path: String(s?.path ?? ''),
|
|
496
|
+
method: String(s?.method ?? 'GET').toUpperCase(),
|
|
497
|
+
params: s?.params ?? [],
|
|
498
|
+
headers: s?.headers ?? {},
|
|
499
|
+
body: str(s?.body),
|
|
500
|
+
row_selector: str(s?.row_selector),
|
|
501
|
+
columns: s?.columns ?? [],
|
|
502
|
+
key_columns: s?.key_columns ?? [],
|
|
503
|
+
operations: s?.operations ?? {},
|
|
504
|
+
})),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** A file's text as a catalog: a pgkiln catalog file, or an OpenAPI / Swagger document (JSON). */
|
|
509
|
+
export function readCatalogText(text: string, opts: { baseUrl?: string; from?: string; name?: string } = {}): { catalog: Catalog; notes: string[] } {
|
|
510
|
+
let doc: unknown;
|
|
511
|
+
try {
|
|
512
|
+
doc = JSON.parse(text);
|
|
513
|
+
} catch {
|
|
514
|
+
throw new Error(/^\s*(openapi|swagger)\s*:/m.test(text)
|
|
515
|
+
? 'This looks like an OpenAPI document in YAML: pgkiln reads JSON. Most services offer their document as JSON too (e.g. openapi.json), or convert it with any YAML tool.'
|
|
516
|
+
: 'The file is not valid JSON.');
|
|
517
|
+
}
|
|
518
|
+
if (isObj(doc) && doc.format === CATALOG_FORMAT) {
|
|
519
|
+
const catalog = fromCatalogDocument(doc);
|
|
520
|
+
if (opts.name?.trim()) catalog.name = opts.name.trim().slice(0, 60);
|
|
521
|
+
if (opts.baseUrl?.trim()) catalog.base_url = trimBase(opts.baseUrl);
|
|
522
|
+
return { catalog, notes: [] };
|
|
523
|
+
}
|
|
524
|
+
return fromOpenApi(doc, opts);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------- storage
|
|
528
|
+
|
|
529
|
+
export interface StoredService extends CatalogService {
|
|
530
|
+
id: number;
|
|
531
|
+
position: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export interface StoredCatalog extends Omit<Catalog, 'services'> {
|
|
535
|
+
id: number;
|
|
536
|
+
workspace_id: number;
|
|
537
|
+
created_by: string | null;
|
|
538
|
+
created_at: string;
|
|
539
|
+
updated_at: string;
|
|
540
|
+
services: StoredService[];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
type Q = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
|
|
544
|
+
|
|
545
|
+
/** A catalog with its services, or null. */
|
|
546
|
+
export async function loadCatalog(id: number | string, db: Q = owner): Promise<StoredCatalog | null> {
|
|
547
|
+
if (!/^\d{1,9}$/.test(String(id))) return null;
|
|
548
|
+
const c = (await db.query('select * from meta.rest_catalog where id = $1', [id])).rows[0];
|
|
549
|
+
if (!c) return null;
|
|
550
|
+
c.services = (await db.query('select * from meta.rest_catalog_service where catalog_id = $1 order by position, name', [id])).rows;
|
|
551
|
+
return c;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Insert a checked catalog into a workspace (inside the caller's transaction); its id. */
|
|
555
|
+
export async function insertCatalog(db: Q, workspaceId: number, c: Catalog, by: string | null): Promise<number> {
|
|
556
|
+
const { id } = (await db.query(
|
|
557
|
+
`insert into meta.rest_catalog (workspace_id, name, description, base_url, source, api_version, created_by)
|
|
558
|
+
values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
559
|
+
[workspaceId, c.name, c.description, c.base_url, c.source, c.api_version, by],
|
|
560
|
+
)).rows[0];
|
|
561
|
+
await insertServices(db, id, c.services);
|
|
562
|
+
return id;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Replace a catalog's fields and services (inside the caller's transaction). */
|
|
566
|
+
export async function replaceCatalog(db: Q, id: number, c: Catalog) {
|
|
567
|
+
await db.query(
|
|
568
|
+
`update meta.rest_catalog set name = $2, description = $3, base_url = $4, source = $5, api_version = $6, updated_at = now() where id = $1`,
|
|
569
|
+
[id, c.name, c.description, c.base_url, c.source, c.api_version],
|
|
570
|
+
);
|
|
571
|
+
await db.query('delete from meta.rest_catalog_service where catalog_id = $1', [id]);
|
|
572
|
+
await insertServices(db, id, c.services);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function insertServices(db: Q, catalogId: number, services: CatalogService[]) {
|
|
576
|
+
let pos = 0;
|
|
577
|
+
for (const s of services)
|
|
578
|
+
await db.query(
|
|
579
|
+
`insert into meta.rest_catalog_service (catalog_id, name, description, path, method, params, headers, body, row_selector, columns, key_columns, operations, position)
|
|
580
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
581
|
+
[catalogId, s.name, s.description, s.path, s.method, JSON.stringify(s.params), JSON.stringify(s.headers ?? {}), s.body, s.row_selector,
|
|
582
|
+
JSON.stringify(s.columns), s.key_columns ?? [], JSON.stringify(s.operations ?? {}), (pos += 10)],
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The values of a REST data source made from a catalog service (meta.rest_source
|
|
588
|
+
* columns): the full URL and a copy of the request and response settings.
|
|
589
|
+
*/
|
|
590
|
+
export function sourceFromService(c: { base_url: string; name: string }, s: CatalogService, opts: { name?: string; credential?: string | null } = {}) {
|
|
591
|
+
return {
|
|
592
|
+
name: (opts.name?.trim() || s.name).toUpperCase(),
|
|
593
|
+
description: s.description ?? `From the REST source catalog ${c.name}.`,
|
|
594
|
+
url: serviceUrl(c.base_url, s.path),
|
|
595
|
+
method: s.method,
|
|
596
|
+
credential: opts.credential?.trim() ? opts.credential.trim().toUpperCase() : null,
|
|
597
|
+
params: s.params,
|
|
598
|
+
headers: s.headers ?? {},
|
|
599
|
+
body: s.body,
|
|
600
|
+
row_selector: s.row_selector,
|
|
601
|
+
columns: s.columns,
|
|
602
|
+
key_columns: s.key_columns ?? [],
|
|
603
|
+
operations: s.operations ?? {},
|
|
604
|
+
};
|
|
605
|
+
}
|