@cube-nl/dmp-sdk 0.0.0-stage → 1.0.0-rc.3
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/LICENSE +21 -0
- package/README.md +690 -2
- package/dist/app.d.ts +87 -0
- package/dist/app.js +245 -0
- package/dist/auth.d.ts +117 -0
- package/dist/auth.js +321 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +3 -0
- package/dist/client.d.ts +102 -0
- package/dist/client.js +220 -0
- package/dist/cube-sdk.js +2 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.js +68 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/preview.d.ts +69 -0
- package/dist/preview.js +676 -0
- package/dist/query.d.ts +9 -0
- package/dist/query.js +121 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +6 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +4 -0
- package/package.json +38 -4
package/dist/preview.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { DmpError } from './errors.js';
|
|
2
|
+
import { buildQuery } from './query.js';
|
|
3
|
+
const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
|
|
4
|
+
const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'like', 'ilike', 'is', 'between'];
|
|
5
|
+
const SYMBOLS = [['>=', 'gte'], ['<=', 'lte'], ['<>', 'neq'], ['>', 'gt'], ['<', 'lt'], ['=', 'eq']];
|
|
6
|
+
const MAX_FILTERS = 20;
|
|
7
|
+
const MAX_IN_VALUES = 200;
|
|
8
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
9
|
+
const MAX_PAGE_SIZE = 200;
|
|
10
|
+
const CONTRACT = '1.0';
|
|
11
|
+
/**
|
|
12
|
+
* The preview's data: every resource of the mock data, in memory, changed by create, update and
|
|
13
|
+
* delete until the page is reloaded. It answers as the REST face does — the same envelope, the same
|
|
14
|
+
* paging, the same refusals as `DmpError` — and never touches the network.
|
|
15
|
+
*/
|
|
16
|
+
export class PreviewStore {
|
|
17
|
+
manifest;
|
|
18
|
+
guard;
|
|
19
|
+
tables = new Map();
|
|
20
|
+
constructor(data, manifest = null,
|
|
21
|
+
/** Runs before every call; throw to refuse it (the preview's signed-out state). */
|
|
22
|
+
guard = () => undefined) {
|
|
23
|
+
this.manifest = manifest;
|
|
24
|
+
this.guard = guard;
|
|
25
|
+
for (const [key, resource] of Object.entries(unwrap(data))) {
|
|
26
|
+
// Not a resource: a comment key such as `_about`, or a value that is no object.
|
|
27
|
+
if (typeof resource !== 'object' || resource === null || Array.isArray(resource)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const name = typeof resource.schema?.name === 'string' && resource.schema.name !== '' ? resource.schema.name : key;
|
|
31
|
+
if (RESOURCE_NAME.test(name)) {
|
|
32
|
+
this.tables.set(name, table(name, resource));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
resourceNames() {
|
|
37
|
+
return [...this.tables.keys()];
|
|
38
|
+
}
|
|
39
|
+
resource(name) {
|
|
40
|
+
if (!RESOURCE_NAME.test(name)) {
|
|
41
|
+
throw new TypeError(`'${name}' is not a resource name: cube/api names match [a-z][a-z0-9_]*.`);
|
|
42
|
+
}
|
|
43
|
+
// Answered asynchronously, like the REST face: a refusal is a rejected promise, never a throw.
|
|
44
|
+
const run = (action, work) => Promise.resolve().then(() => work(this.authorize(name, action)));
|
|
45
|
+
const list = (query = {}) => run('list', (table) => listRows(table, query));
|
|
46
|
+
const pages = async function* (query = {}) {
|
|
47
|
+
let number = query.page?.number ?? 1;
|
|
48
|
+
while (true) {
|
|
49
|
+
const page = await list({ ...query, page: { ...query.page, number } });
|
|
50
|
+
yield page;
|
|
51
|
+
if (page.meta.page.number >= page.meta.page.pages || page.data.length === 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
number = page.meta.page.number + 1;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
list,
|
|
60
|
+
pages,
|
|
61
|
+
async *all(query = {}) {
|
|
62
|
+
for await (const page of pages(query)) {
|
|
63
|
+
yield* page.data;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
get: (id, query = {}) => run('get', (table) => getRow(table, id, query)),
|
|
67
|
+
aggregate: (query) => run('list', (table) => aggregateRows(table, query)),
|
|
68
|
+
create: (body, options = {}) => run('create', (table) => createRow(table, body, options)),
|
|
69
|
+
update: (id, body, options = {}) => run('update', (table) => updateRow(table, id, body, options)),
|
|
70
|
+
delete: (id, options = {}) => run('delete', (table) => deleteRow(table, id, options)),
|
|
71
|
+
operation: (id, operation, _input, _options) => run('get', (table) => {
|
|
72
|
+
findRow(table, id);
|
|
73
|
+
throw noSuchOperation(operation);
|
|
74
|
+
}),
|
|
75
|
+
collectionOperation: (operation) => run('list', () => {
|
|
76
|
+
throw noSuchOperation(operation);
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Unknown resource first (404, as the REST face answers it), then the manifest: live, the app's
|
|
82
|
+
* OAuth client is capped to its `uses`, so the preview refuses what the live app would.
|
|
83
|
+
*/
|
|
84
|
+
authorize(name, action) {
|
|
85
|
+
this.guard();
|
|
86
|
+
const table = this.tables.get(name);
|
|
87
|
+
if (table === undefined) {
|
|
88
|
+
throw problem(404, 'not_found', `Unknown API resource [${name}].`);
|
|
89
|
+
}
|
|
90
|
+
const uses = this.manifest?.uses;
|
|
91
|
+
if (uses !== undefined && !(uses[name] ?? []).includes(action)) {
|
|
92
|
+
throw problem(403, 'client_scope_exceeded', `This app may not ${action} '${name}': add it to "uses" in cube-app.json.`);
|
|
93
|
+
}
|
|
94
|
+
return table;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function unwrap(data) {
|
|
98
|
+
const wrapped = data.resources;
|
|
99
|
+
if (Object.keys(data).length === 1 && isObject(wrapped) && !('rows' in wrapped) && !('schema' in wrapped)) {
|
|
100
|
+
return wrapped;
|
|
101
|
+
}
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
function table(name, resource) {
|
|
105
|
+
const schema = resource.schema ?? {};
|
|
106
|
+
const rows = (resource.rows ?? []).map((row) => clone(row));
|
|
107
|
+
const fields = new Map();
|
|
108
|
+
const declared = Array.isArray(schema.fields)
|
|
109
|
+
? schema.fields.map((field) => [field.name, field])
|
|
110
|
+
: Object.entries((schema.fields ?? {}));
|
|
111
|
+
for (const [fieldName, definition] of declared) {
|
|
112
|
+
fields.set(fieldName, field(fieldName, definition));
|
|
113
|
+
}
|
|
114
|
+
// Without a schema the rows are the schema: every key any row carries is a field.
|
|
115
|
+
for (const row of fields.size === 0 ? rows : []) {
|
|
116
|
+
for (const key of Object.keys(row)) {
|
|
117
|
+
if (!fields.has(key)) {
|
|
118
|
+
fields.set(key, { name: key, types: [], nullable: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const names = [...fields.keys()];
|
|
123
|
+
const readonly = new Set([
|
|
124
|
+
'id',
|
|
125
|
+
...(schema.readonly ?? []),
|
|
126
|
+
...declared.filter(([, definition]) => definition.readonly === true).map(([fieldName]) => fieldName),
|
|
127
|
+
]);
|
|
128
|
+
const known = (list) => list === undefined ? null : list.filter((item) => fields.has(item));
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
fields,
|
|
132
|
+
filterable: known(schema.filters) ?? names,
|
|
133
|
+
writable: new Set(names.filter((fieldName) => !readonly.has(fieldName))),
|
|
134
|
+
summary: (known(schema.summary_fields) ?? []).length > 0 ? known(schema.summary_fields) : names,
|
|
135
|
+
rows,
|
|
136
|
+
versions: new Map(rows.map((row) => [key(row.id), 1])),
|
|
137
|
+
replays: new Map(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function field(name, definition) {
|
|
141
|
+
const raw = definition.type;
|
|
142
|
+
const list = typeof raw === 'string' ? raw.split('|') : Array.isArray(raw) ? [...raw] : [];
|
|
143
|
+
return {
|
|
144
|
+
name,
|
|
145
|
+
types: list.filter((type) => type !== 'null' && type !== ''),
|
|
146
|
+
nullable: definition.nullable === true || list.includes('null'),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The query is turned into the exact query string the live client would send (`buildQuery`), then
|
|
151
|
+
* read back the way the REST face reads it. The preview so refuses what the API refuses, and a
|
|
152
|
+
* mistake `buildQuery` catches before a request (a bare array as a filter) is the same TypeError.
|
|
153
|
+
*/
|
|
154
|
+
function parse(table, query) {
|
|
155
|
+
return new URLSearchParams(buildQuery(table.name, query));
|
|
156
|
+
}
|
|
157
|
+
function listRows(table, query) {
|
|
158
|
+
const params = parse(table, query);
|
|
159
|
+
const selected = selection(table, params);
|
|
160
|
+
const filters = readFilters(table, params);
|
|
161
|
+
const sort = readSort(table, params);
|
|
162
|
+
const { size, number } = readPage(params);
|
|
163
|
+
const omitted = relations(table, params);
|
|
164
|
+
let rows = table.rows.filter((row) => filters.every((filter) => matches(table, row, filter)));
|
|
165
|
+
if (sort.length > 0) {
|
|
166
|
+
rows = [...rows].sort((a, b) => {
|
|
167
|
+
for (const { field: name, descending } of sort) {
|
|
168
|
+
const order = compare(a[name], b[name]);
|
|
169
|
+
if (order !== 0) {
|
|
170
|
+
return descending ? -order : order;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return 0;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const total = rows.length;
|
|
177
|
+
const data = rows.slice((number - 1) * size, number * size).map((row) => project(row, selected));
|
|
178
|
+
const page = { size, number, total, pages: Math.max(1, Math.ceil(total / size)) };
|
|
179
|
+
return { ...response(200, data, meta(table, page, omitted)), meta: { ...meta(table, page, omitted), page } };
|
|
180
|
+
}
|
|
181
|
+
function getRow(table, id, query) {
|
|
182
|
+
const params = parse(table, query);
|
|
183
|
+
const selected = selection(table, params);
|
|
184
|
+
const omitted = relations(table, params);
|
|
185
|
+
const row = findRow(table, id);
|
|
186
|
+
return response(200, project(row, selected), meta(table, null, omitted), version(table, row));
|
|
187
|
+
}
|
|
188
|
+
function selection(table, params) {
|
|
189
|
+
const parameter = `fields[${table.name}]`;
|
|
190
|
+
const raw = params.get(parameter);
|
|
191
|
+
if (raw === null || raw === '') {
|
|
192
|
+
return table.summary;
|
|
193
|
+
}
|
|
194
|
+
const requested = raw.split(',').map((item) => item.trim());
|
|
195
|
+
const unknown = requested.filter((item) => !table.fields.has(item));
|
|
196
|
+
if (unknown.length > 0) {
|
|
197
|
+
throw problem(422, 'query_invalid', `Unknown ${parameter}: ${unknown.join(', ')}.`, {
|
|
198
|
+
[parameter]: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return requested;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Mock data names no relations, so the preview cannot follow one. A requested include or count
|
|
205
|
+
* comes back the way the REST face reports a relation the caller may not see: in `meta.omitted`,
|
|
206
|
+
* never as an empty list that looks like data.
|
|
207
|
+
*/
|
|
208
|
+
function relations(_table, params) {
|
|
209
|
+
const list = (key) => (params.get(key) ?? '').split(',').filter((item) => item !== '');
|
|
210
|
+
return [...new Set([...list('include'), ...list('with_count')])];
|
|
211
|
+
}
|
|
212
|
+
function readFilters(table, params) {
|
|
213
|
+
const filters = [];
|
|
214
|
+
const entries = [];
|
|
215
|
+
params.forEach((value, name) => entries.push([name, value]));
|
|
216
|
+
for (const [key, value] of entries) {
|
|
217
|
+
const match = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/.exec(key);
|
|
218
|
+
if (match === null) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const name = match[1];
|
|
222
|
+
if (!table.filterable.includes(name)) {
|
|
223
|
+
throw problem(422, 'query_invalid', `Unknown filter: ${name}.`, {
|
|
224
|
+
filter: [`Allowed values: ${table.filterable.join(', ')}.`],
|
|
225
|
+
}, 'not_filterable');
|
|
226
|
+
}
|
|
227
|
+
filters.push(match[2] === undefined ? fromSymbol(name, value) : operand(name, match[2], value));
|
|
228
|
+
}
|
|
229
|
+
if (filters.length > MAX_FILTERS) {
|
|
230
|
+
throw problem(422, 'query_invalid', `A query takes at most ${MAX_FILTERS} filters.`, {
|
|
231
|
+
filter: [`Sent ${filters.length}; the limit is ${MAX_FILTERS}.`],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return filters;
|
|
235
|
+
}
|
|
236
|
+
function fromSymbol(name, value) {
|
|
237
|
+
for (const [symbol, operator] of SYMBOLS) {
|
|
238
|
+
if (value.startsWith(symbol)) {
|
|
239
|
+
return { field: name, operator, value: value.slice(symbol.length) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { field: name, operator: 'eq', value };
|
|
243
|
+
}
|
|
244
|
+
function operand(name, operator, value) {
|
|
245
|
+
if (!OPERATORS.includes(operator)) {
|
|
246
|
+
throw problem(422, 'query_invalid', `Unknown filter operator: ${operator}.`, {
|
|
247
|
+
filter: [`Allowed operators: ${OPERATORS.join(', ')}.`],
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (operator === 'between' && value.split(',').length !== 2) {
|
|
251
|
+
throw problem(422, 'query_invalid', `filter[${name}][between] takes exactly 2 values.`, {
|
|
252
|
+
filter: [`Write filter[${name}][between]=low,high. Received ${value.split(',').length} value(s).`],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (operator === 'in' && value.split(',').length > MAX_IN_VALUES) {
|
|
256
|
+
throw problem(422, 'query_invalid', `filter[${name}][in] takes at most ${MAX_IN_VALUES} values.`, {
|
|
257
|
+
filter: [`Sent ${value.split(',').length}; the limit is ${MAX_IN_VALUES}.`],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (operator === 'is' && value !== 'null' && value !== 'not_null') {
|
|
261
|
+
throw problem(422, 'query_invalid', `filter[${name}][is] takes null or not_null.`, {
|
|
262
|
+
filter: [`Received: ${value}.`],
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return { field: name, operator, value };
|
|
266
|
+
}
|
|
267
|
+
function matches(table, row, filter) {
|
|
268
|
+
const actual = row[filter.field];
|
|
269
|
+
const typed = (value) => coerce(table.fields.get(filter.field), value.trim());
|
|
270
|
+
switch (filter.operator) {
|
|
271
|
+
case 'eq':
|
|
272
|
+
return same(actual, typed(filter.value));
|
|
273
|
+
case 'neq':
|
|
274
|
+
// As SQL's `<>`: a null is neither equal nor unequal.
|
|
275
|
+
return actual != null && !same(actual, typed(filter.value));
|
|
276
|
+
case 'gt':
|
|
277
|
+
return actual != null && compare(actual, typed(filter.value)) > 0;
|
|
278
|
+
case 'gte':
|
|
279
|
+
return actual != null && compare(actual, typed(filter.value)) >= 0;
|
|
280
|
+
case 'lt':
|
|
281
|
+
return actual != null && compare(actual, typed(filter.value)) < 0;
|
|
282
|
+
case 'lte':
|
|
283
|
+
return actual != null && compare(actual, typed(filter.value)) <= 0;
|
|
284
|
+
case 'in':
|
|
285
|
+
return filter.value.split(',').some((item) => same(actual, typed(item)));
|
|
286
|
+
case 'between': {
|
|
287
|
+
const [low, high] = filter.value.split(',').map(typed);
|
|
288
|
+
return actual != null && compare(actual, low) >= 0 && compare(actual, high) <= 0;
|
|
289
|
+
}
|
|
290
|
+
case 'like':
|
|
291
|
+
case 'ilike':
|
|
292
|
+
return typeof actual === 'string' && like(actual, filter.value);
|
|
293
|
+
case 'is':
|
|
294
|
+
return filter.value === 'null' ? actual == null : actual != null;
|
|
295
|
+
default:
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** As the engine: a substring match where `%` is a wildcard and `_` a literal, case-insensitive. */
|
|
300
|
+
function like(actual, pattern) {
|
|
301
|
+
const source = pattern.split('%').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
302
|
+
return new RegExp(source, 'iu').test(actual);
|
|
303
|
+
}
|
|
304
|
+
function readSort(table, params) {
|
|
305
|
+
const raw = params.get('sort');
|
|
306
|
+
if (raw === null || raw === '') {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const sort = raw.split(',').map((item) => item.trim()).filter((item) => item !== '').map((item) => ({
|
|
310
|
+
field: item.replace(/^[-+]/, ''),
|
|
311
|
+
descending: item.startsWith('-'),
|
|
312
|
+
}));
|
|
313
|
+
const unknown = sort.map((item) => item.field).filter((name) => !table.fields.has(name));
|
|
314
|
+
if (unknown.length > 0) {
|
|
315
|
+
throw problem(422, 'query_invalid', `Unknown sort: ${unknown.join(', ')}.`, {
|
|
316
|
+
sort: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
|
|
317
|
+
}, 'not_sortable');
|
|
318
|
+
}
|
|
319
|
+
return sort;
|
|
320
|
+
}
|
|
321
|
+
function readPage(params) {
|
|
322
|
+
const integer = (parameter) => {
|
|
323
|
+
const raw = params.get(parameter);
|
|
324
|
+
if (raw === null || raw === '') {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
if (!/^\d+$/.test(raw)) {
|
|
328
|
+
throw problem(422, 'query_invalid', `${parameter} must be a positive integer.`, {
|
|
329
|
+
[parameter]: ['Expected a positive integer.'],
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return Number(raw);
|
|
333
|
+
};
|
|
334
|
+
const size = integer('page[size]') ?? DEFAULT_PAGE_SIZE;
|
|
335
|
+
// Refused rather than clamped, as on the REST face.
|
|
336
|
+
if (size > MAX_PAGE_SIZE) {
|
|
337
|
+
throw problem(422, 'cardinality_exceeded', `page[size] may not exceed ${MAX_PAGE_SIZE}.`, {
|
|
338
|
+
'page.size': [`The maximum page size is ${MAX_PAGE_SIZE}.`],
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return { size: Math.max(1, size), number: Math.max(1, integer('page[number]') ?? 1) };
|
|
342
|
+
}
|
|
343
|
+
function aggregateRows(table, query) {
|
|
344
|
+
const params = parse(table, query);
|
|
345
|
+
const filters = readFilters(table, params);
|
|
346
|
+
const functions = (params.get('fn') ?? '').split(',').filter((item) => item !== '');
|
|
347
|
+
const groups = (params.get('group') ?? '').split(',').filter((item) => item !== '');
|
|
348
|
+
if (functions.length === 0) {
|
|
349
|
+
throw problem(422, 'query_invalid', 'fn is required.', { fn: ['Name at least one function, e.g. fn=count.'] });
|
|
350
|
+
}
|
|
351
|
+
const readers = functions.map((fn) => aggregator(table, fn));
|
|
352
|
+
const dimensions = groups.map((group) => dimension(table, group));
|
|
353
|
+
const buckets = new Map();
|
|
354
|
+
for (const row of table.rows.filter((candidate) => filters.every((filter) => matches(table, candidate, filter)))) {
|
|
355
|
+
const key = {};
|
|
356
|
+
for (const [index, group] of groups.entries()) {
|
|
357
|
+
key[group] = dimensions[index](row);
|
|
358
|
+
}
|
|
359
|
+
const id = JSON.stringify(Object.values(key));
|
|
360
|
+
const bucket = buckets.get(id) ?? { key, rows: [] };
|
|
361
|
+
bucket.rows.push(row);
|
|
362
|
+
buckets.set(id, bucket);
|
|
363
|
+
}
|
|
364
|
+
if (groups.length === 0 && buckets.size === 0) {
|
|
365
|
+
buckets.set('[]', { key: {}, rows: [] });
|
|
366
|
+
}
|
|
367
|
+
const data = [...buckets.values()].map(({ key, rows }) => {
|
|
368
|
+
const result = { ...key };
|
|
369
|
+
for (const [index, fn] of functions.entries()) {
|
|
370
|
+
result[fn] = readers[index](rows);
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
});
|
|
374
|
+
return response(200, data, { ...meta(table, null, []), groups: groups.length === 0 ? null : data.length });
|
|
375
|
+
}
|
|
376
|
+
function aggregator(table, fn) {
|
|
377
|
+
if (fn === 'count') {
|
|
378
|
+
return (rows) => rows.length;
|
|
379
|
+
}
|
|
380
|
+
const match = /^(count|sum|avg|min|max)\(([^)]+)\)$/.exec(fn);
|
|
381
|
+
if (match === null) {
|
|
382
|
+
throw problem(422, 'query_invalid', `Unknown function: ${fn}.`, {
|
|
383
|
+
fn: ['Allowed: count, count(field), sum(field), avg(field), min(field), max(field).'],
|
|
384
|
+
}, 'unknown_function');
|
|
385
|
+
}
|
|
386
|
+
const [, name, target] = match;
|
|
387
|
+
if (!table.fields.has(target)) {
|
|
388
|
+
throw problem(422, 'query_invalid', `Unknown fn: ${target}.`, {
|
|
389
|
+
fn: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
|
|
390
|
+
}, 'not_aggregatable');
|
|
391
|
+
}
|
|
392
|
+
return (rows) => {
|
|
393
|
+
const values = rows.map((row) => row[target]).filter((value) => value != null);
|
|
394
|
+
const numbers = values.map(Number).filter((value) => Number.isFinite(value));
|
|
395
|
+
switch (name) {
|
|
396
|
+
case 'count':
|
|
397
|
+
return values.length;
|
|
398
|
+
case 'sum':
|
|
399
|
+
return numbers.reduce((sum, value) => sum + value, 0);
|
|
400
|
+
case 'avg':
|
|
401
|
+
return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
|
|
402
|
+
case 'min':
|
|
403
|
+
return values.length === 0 ? null : values.reduce((low, value) => (compare(value, low) < 0 ? value : low));
|
|
404
|
+
default:
|
|
405
|
+
return values.length === 0 ? null : values.reduce((high, value) => (compare(value, high) > 0 ? value : high));
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function dimension(table, group) {
|
|
410
|
+
const [name, truncation] = group.split('.');
|
|
411
|
+
if (!table.fields.has(name)) {
|
|
412
|
+
throw problem(422, 'query_invalid', `Unknown group: ${name}.`, {
|
|
413
|
+
group: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
|
|
414
|
+
}, 'not_groupable');
|
|
415
|
+
}
|
|
416
|
+
const lengths = { year: 4, month: 7, day: 10 };
|
|
417
|
+
if (truncation === undefined) {
|
|
418
|
+
return (row) => row[name] ?? null;
|
|
419
|
+
}
|
|
420
|
+
const length = lengths[truncation];
|
|
421
|
+
if (length === undefined) {
|
|
422
|
+
throw problem(422, 'query_invalid', `Unknown truncation: ${truncation}.`, {
|
|
423
|
+
group: ['Allowed truncations in the preview: year, month, day.'],
|
|
424
|
+
}, 'unknown_truncation');
|
|
425
|
+
}
|
|
426
|
+
return (row) => (typeof row[name] === 'string' ? row[name].slice(0, length) : null);
|
|
427
|
+
}
|
|
428
|
+
// ---------------------------------------------------------------------------------------------
|
|
429
|
+
// Writing
|
|
430
|
+
// ---------------------------------------------------------------------------------------------
|
|
431
|
+
function createRow(table, body, options) {
|
|
432
|
+
const selected = options.fields === undefined ? table.summary : selection(table, parse(table, { fields: options.fields }));
|
|
433
|
+
const fingerprint = JSON.stringify([body, options.fields ?? null]);
|
|
434
|
+
if (options.idempotencyKey !== undefined) {
|
|
435
|
+
const earlier = table.replays.get(options.idempotencyKey);
|
|
436
|
+
if (earlier !== undefined) {
|
|
437
|
+
if (earlier.body !== fingerprint) {
|
|
438
|
+
throw problem(422, 'validation_failed', 'This Idempotency-Key was already used with a different '
|
|
439
|
+
+ 'request body. Use a new key, or resend the original body.');
|
|
440
|
+
}
|
|
441
|
+
return { ...earlier.response, replayed: true };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const values = validate(table, body);
|
|
445
|
+
const row = { id: nextId(table), ...values };
|
|
446
|
+
for (const name of table.fields.keys()) {
|
|
447
|
+
if (!(name in row)) {
|
|
448
|
+
row[name] = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
table.rows.push(row);
|
|
452
|
+
table.versions.set(key(row.id), 1);
|
|
453
|
+
const created = response(201, project(row, selected), meta(table, null, []), version(table, row));
|
|
454
|
+
if (options.idempotencyKey !== undefined) {
|
|
455
|
+
table.replays.set(options.idempotencyKey, { body: fingerprint, response: created });
|
|
456
|
+
}
|
|
457
|
+
return created;
|
|
458
|
+
}
|
|
459
|
+
function updateRow(table, id, body, options) {
|
|
460
|
+
const selected = options.fields === undefined ? table.summary : selection(table, parse(table, { fields: options.fields }));
|
|
461
|
+
const row = findRow(table, id);
|
|
462
|
+
checkVersion(table, row, options.ifMatch);
|
|
463
|
+
Object.assign(row, validate(table, body));
|
|
464
|
+
table.versions.set(key(row.id), (table.versions.get(key(row.id)) ?? 1) + 1);
|
|
465
|
+
return response(200, project(row, selected), meta(table, null, []), version(table, row));
|
|
466
|
+
}
|
|
467
|
+
function deleteRow(table, id, options) {
|
|
468
|
+
const row = findRow(table, id);
|
|
469
|
+
checkVersion(table, row, options.ifMatch);
|
|
470
|
+
table.rows.splice(table.rows.indexOf(row), 1);
|
|
471
|
+
table.versions.delete(key(row.id));
|
|
472
|
+
return response(204, null, null);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The REST face's two refusals for a body: a key that is not writable (422 `query_invalid`, per
|
|
476
|
+
* field) before any value is looked at, then a value of the wrong type (422 `validation_failed`).
|
|
477
|
+
*/
|
|
478
|
+
function validate(table, body) {
|
|
479
|
+
const writable = [...table.writable];
|
|
480
|
+
const message = writable.length === 0
|
|
481
|
+
? 'This resource declares no writable fields.'
|
|
482
|
+
: `Writable fields: ${writable.join(', ')}.`;
|
|
483
|
+
if (!isObject(body) || Object.keys(body).length === 0) {
|
|
484
|
+
throw problem(422, 'validation_failed', 'The request body must be a JSON object naming at least one writable field.', {
|
|
485
|
+
body: [message],
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const refused = Object.keys(body).filter((name) => !table.writable.has(name));
|
|
489
|
+
if (refused.length > 0) {
|
|
490
|
+
throw problem(422, 'query_invalid', `Unknown or read-only fields: ${refused.join(', ')}.`, Object.fromEntries(refused.map((name) => [name, [message]])));
|
|
491
|
+
}
|
|
492
|
+
const errors = {};
|
|
493
|
+
for (const [name, value] of Object.entries(body)) {
|
|
494
|
+
const problemText = typeError(table.fields.get(name), value);
|
|
495
|
+
if (problemText !== null) {
|
|
496
|
+
errors[name] = [problemText];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (Object.keys(errors).length > 0) {
|
|
500
|
+
throw problem(422, 'validation_failed', 'The request body failed validation.', errors);
|
|
501
|
+
}
|
|
502
|
+
return clone(body);
|
|
503
|
+
}
|
|
504
|
+
/** Laravel's rules and wording for the one declared type, as the REST face's TypeRules applies them. */
|
|
505
|
+
function typeError(definition, value) {
|
|
506
|
+
if (definition === undefined || definition.types.length !== 1) {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
if (value === null) {
|
|
510
|
+
return definition.nullable ? null : messageFor(definition);
|
|
511
|
+
}
|
|
512
|
+
const valid = (() => {
|
|
513
|
+
switch (definition.types[0]) {
|
|
514
|
+
case 'string':
|
|
515
|
+
return typeof value === 'string';
|
|
516
|
+
case 'integer':
|
|
517
|
+
return (typeof value === 'number' && Number.isInteger(value))
|
|
518
|
+
|| (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim()));
|
|
519
|
+
case 'number':
|
|
520
|
+
return (typeof value === 'number' && Number.isFinite(value))
|
|
521
|
+
|| (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
|
|
522
|
+
case 'boolean':
|
|
523
|
+
return [true, false, 0, 1, '0', '1'].includes(value);
|
|
524
|
+
case 'array':
|
|
525
|
+
case 'object':
|
|
526
|
+
return typeof value === 'object';
|
|
527
|
+
default:
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
})();
|
|
531
|
+
return valid ? null : messageFor(definition);
|
|
532
|
+
}
|
|
533
|
+
function messageFor(definition) {
|
|
534
|
+
const attribute = definition.name.replace(/_/g, ' ');
|
|
535
|
+
const kind = {
|
|
536
|
+
string: 'a string',
|
|
537
|
+
integer: 'an integer',
|
|
538
|
+
number: 'a number',
|
|
539
|
+
boolean: 'true or false',
|
|
540
|
+
array: 'an array',
|
|
541
|
+
object: 'an array',
|
|
542
|
+
};
|
|
543
|
+
return `The ${attribute} field must be ${kind[definition.types[0]] ?? 'valid'}.`;
|
|
544
|
+
}
|
|
545
|
+
function checkVersion(table, row, ifMatch) {
|
|
546
|
+
if (ifMatch === undefined) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (ifMatch.startsWith('W/')) {
|
|
550
|
+
throw new TypeError('ifMatch takes recordVersion (X-Dmp-Record-Version), not the weak ETag.');
|
|
551
|
+
}
|
|
552
|
+
const current = version(table, row);
|
|
553
|
+
if (ifMatch !== current) {
|
|
554
|
+
throw problem(409, 'conflict', 'The record changed since the version you sent.', undefined, undefined, {
|
|
555
|
+
'X-Dmp-Record-Version': current,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function nextId(table) {
|
|
560
|
+
const ids = table.rows.map((row) => row.id);
|
|
561
|
+
if (ids.length > 0 && ids.every((id) => typeof id === 'string') && ids.some((id) => !/^\d+$/.test(id))) {
|
|
562
|
+
return crypto.randomUUID();
|
|
563
|
+
}
|
|
564
|
+
return ids.reduce((max, id) => Math.max(max, Number(id) || 0), 0) + 1;
|
|
565
|
+
}
|
|
566
|
+
// ---------------------------------------------------------------------------------------------
|
|
567
|
+
// Shared
|
|
568
|
+
// ---------------------------------------------------------------------------------------------
|
|
569
|
+
function findRow(table, id) {
|
|
570
|
+
const wanted = key(id);
|
|
571
|
+
if (wanted === '' || wanted === '.' || wanted === '..') {
|
|
572
|
+
throw new TypeError(`${table.name}: '${wanted}' is not an id.`);
|
|
573
|
+
}
|
|
574
|
+
const row = table.rows.find((candidate) => key(candidate.id) === wanted);
|
|
575
|
+
if (row === undefined) {
|
|
576
|
+
throw problem(404, 'not_found', `No such ${table.name} record.`);
|
|
577
|
+
}
|
|
578
|
+
return row;
|
|
579
|
+
}
|
|
580
|
+
function noSuchOperation(operation) {
|
|
581
|
+
return problem(404, 'not_found', `No such operation [${operation}]. The preview runs no custom operations.`);
|
|
582
|
+
}
|
|
583
|
+
function project(row, selected) {
|
|
584
|
+
const result = {};
|
|
585
|
+
for (const name of selected) {
|
|
586
|
+
result[name] = name in row ? clone(row[name]) : null;
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
function version(table, row) {
|
|
591
|
+
return `"preview-${key(row.id)}-${table.versions.get(key(row.id)) ?? 1}"`;
|
|
592
|
+
}
|
|
593
|
+
function meta(table, page, omitted) {
|
|
594
|
+
return { resource: table.name, contract: CONTRACT, page, groups: null, omitted, pruned: [], forbidden_fields: [] };
|
|
595
|
+
}
|
|
596
|
+
function response(status, data, body, recordVersion = null) {
|
|
597
|
+
const headers = new Headers({ 'X-Dmp-Preview': 'true' });
|
|
598
|
+
if (recordVersion !== null) {
|
|
599
|
+
headers.set('X-Dmp-Record-Version', recordVersion);
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
status,
|
|
603
|
+
data,
|
|
604
|
+
meta: body,
|
|
605
|
+
headers,
|
|
606
|
+
etag: null,
|
|
607
|
+
recordVersion,
|
|
608
|
+
location: null,
|
|
609
|
+
replayed: false,
|
|
610
|
+
rateLimit: { limit: null, remaining: null, reset: null },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/** A refusal shaped exactly as the REST face writes it, so the app's error handling is the same code. */
|
|
614
|
+
function problem(status, code, detail, errors, reason, headers = {}) {
|
|
615
|
+
return new DmpError(status, new Headers({ 'Content-Type': 'application/problem+json', ...headers }), {
|
|
616
|
+
type: `https://docs.cube.nl/dmp-api/problems/${code}`,
|
|
617
|
+
title: code.replace(/_/g, ' '),
|
|
618
|
+
status,
|
|
619
|
+
code,
|
|
620
|
+
...(reason !== undefined ? { reason } : {}),
|
|
621
|
+
detail,
|
|
622
|
+
...(errors !== undefined ? { errors } : {}),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/** A filter value from the query string, typed after the field it compares against. */
|
|
626
|
+
function coerce(definition, value) {
|
|
627
|
+
const type = definition?.types[0];
|
|
628
|
+
if ((type === 'integer' || type === 'number') && value !== '' && Number.isFinite(Number(value))) {
|
|
629
|
+
return Number(value);
|
|
630
|
+
}
|
|
631
|
+
if (type === 'boolean') {
|
|
632
|
+
if (value === '1' || value === 'true') {
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
if (value === '0' || value === 'false') {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return value;
|
|
640
|
+
}
|
|
641
|
+
function same(actual, expected) {
|
|
642
|
+
if (actual == null) {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
if (typeof actual === 'boolean' && typeof expected !== 'boolean') {
|
|
646
|
+
return (actual ? '1' : '0') === String(expected) || String(actual) === String(expected);
|
|
647
|
+
}
|
|
648
|
+
if (typeof actual === 'number' && typeof expected === 'string') {
|
|
649
|
+
return String(actual) === expected;
|
|
650
|
+
}
|
|
651
|
+
return actual === expected || (typeof actual !== 'object' && String(actual) === String(expected));
|
|
652
|
+
}
|
|
653
|
+
/** Nulls first, numbers as numbers, everything else as text (ISO dates sort correctly as text). */
|
|
654
|
+
function compare(a, b) {
|
|
655
|
+
if (a == null || b == null) {
|
|
656
|
+
return a == null ? (b == null ? 0 : -1) : 1;
|
|
657
|
+
}
|
|
658
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
659
|
+
return a - b;
|
|
660
|
+
}
|
|
661
|
+
if (typeof a === 'boolean' || typeof b === 'boolean') {
|
|
662
|
+
return Number(a === true || a === 1 || a === '1') - Number(b === true || b === 1 || b === '1');
|
|
663
|
+
}
|
|
664
|
+
const left = String(a);
|
|
665
|
+
const right = String(b);
|
|
666
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
667
|
+
}
|
|
668
|
+
function key(id) {
|
|
669
|
+
return id === null || id === undefined ? '' : String(id);
|
|
670
|
+
}
|
|
671
|
+
function isObject(value) {
|
|
672
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
673
|
+
}
|
|
674
|
+
function clone(value) {
|
|
675
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
676
|
+
}
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AggregateQuery, GetQuery, ListQuery } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turn a query description into cube/api's query string, without the leading `?`.
|
|
4
|
+
*
|
|
5
|
+
* Mistakes the API would refuse anyway, and that this SDK can see coming, are thrown here as a
|
|
6
|
+
* TypeError before a request is made: a bare array as a filter value (`filter[x][]=a` names no
|
|
7
|
+
* operator and is refused — write `{ in: [...] }`), or a list under an operator that takes one value.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildQuery(resource: string, query?: ListQuery & GetQuery & Partial<AggregateQuery>): string;
|