@haverstack/core 0.37.0 → 0.39.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/README.md +19 -15
- package/dist/access.d.ts +4 -6
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +7 -8
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +7 -6
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +6 -5
- package/dist/adapter-entry.js.map +1 -1
- package/dist/attachment-download.d.ts +3 -1
- package/dist/attachment-download.d.ts.map +1 -1
- package/dist/attachment-download.js +1 -1
- package/dist/attachment-download.js.map +1 -1
- package/dist/changes.d.ts +22 -16
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +43 -32
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts +1 -1
- package/dist/combine.js +6 -6
- package/dist/combine.js.map +1 -1
- package/dist/errors.d.ts +24 -12
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +32 -17
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +17 -25
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +27 -14
- package/dist/grants.js.map +1 -1
- package/dist/identity-bindings.js +2 -2
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +28 -11
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +95 -36
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +11 -10
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +33 -25
- package/dist/record-changes.js.map +1 -1
- package/dist/record-id.d.ts +3 -3
- package/dist/record-id.js +8 -8
- package/dist/record-id.js.map +1 -1
- package/dist/scoped-stack.d.ts +71 -34
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +107 -88
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack-reads.d.ts +2 -2
- package/dist/stack-reads.js +5 -5
- package/dist/stack-reads.js.map +1 -1
- package/dist/stack.d.ts +132 -161
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +287 -288
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +17 -17
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +46 -42
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +169 -143
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/wire-body.d.ts +71 -0
- package/dist/wire-body.d.ts.map +1 -0
- package/dist/wire-body.js +138 -0
- package/dist/wire-body.js.map +1 -0
- package/dist/wire-entry.d.ts +9 -8
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +13 -14
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-record.d.ts +4 -4
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js +35 -22
- package/dist/wire-record.js.map +1 -1
- package/dist/wire-request.d.ts +37 -4
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +288 -79
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
package/dist/wire-request.js
CHANGED
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
* `Stack`'s to judge, and judging it twice would let the two answers
|
|
21
21
|
* drift.
|
|
22
22
|
*
|
|
23
|
-
* Everything here throws `
|
|
23
|
+
* Everything here throws `StackBadRequestError` on malformed input, which
|
|
24
24
|
* servers already map to 400 — see docs/spec/wire-format.md § Error
|
|
25
25
|
* responses.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import { StackBadRequestError } from './errors.js';
|
|
28
|
+
import { TARGET_KEYS } from './query-validation.js';
|
|
28
29
|
import { NATIVE_SORT_FIELDS } from './types.js';
|
|
29
30
|
// -------------------------------------------------------
|
|
30
31
|
// Shared primitives
|
|
@@ -40,7 +41,70 @@ const POSITIVE_INTEGER = /^\d+$/;
|
|
|
40
41
|
const SORT_FIELDS = new Set(NATIVE_SORT_FIELDS);
|
|
41
42
|
const SORT_DIRECTIONS = new Set(['asc', 'desc']);
|
|
42
43
|
const CHANGE_KINDS = new Set(['created', 'changed', 'deleted', 'purged']);
|
|
43
|
-
const
|
|
44
|
+
const ASSOCIATION_KINDS = new Set(['tag', 'attachment', 'relationship']);
|
|
45
|
+
const TARGET_KINDS = new Set(['record', 'entity', 'external']);
|
|
46
|
+
/** Every param `GET /records` defines. See docs/spec/wire-format.md § Records. */
|
|
47
|
+
const RECORD_QUERY_PARAMS = [
|
|
48
|
+
'typeId',
|
|
49
|
+
'parentId',
|
|
50
|
+
'appId',
|
|
51
|
+
'createdBySubject',
|
|
52
|
+
'createdByPrincipal',
|
|
53
|
+
'tag',
|
|
54
|
+
'attachmentLabel',
|
|
55
|
+
'attachmentFileId',
|
|
56
|
+
'referencesFileId',
|
|
57
|
+
'relatedTo',
|
|
58
|
+
'relatedToStack',
|
|
59
|
+
'relatedToEntity',
|
|
60
|
+
'relatedToNs',
|
|
61
|
+
'relatedToId',
|
|
62
|
+
'relatedToLabel',
|
|
63
|
+
'search',
|
|
64
|
+
'createdBefore',
|
|
65
|
+
'createdAfter',
|
|
66
|
+
'updatedBefore',
|
|
67
|
+
'updatedAfter',
|
|
68
|
+
'includeDeleted',
|
|
69
|
+
'includeUnlisted',
|
|
70
|
+
'sort',
|
|
71
|
+
'sortContent',
|
|
72
|
+
'direction',
|
|
73
|
+
'limit',
|
|
74
|
+
'cursor',
|
|
75
|
+
];
|
|
76
|
+
const QUERY_BODY_FILTER_KEYS = [
|
|
77
|
+
'typeId',
|
|
78
|
+
'parentId',
|
|
79
|
+
'appId',
|
|
80
|
+
'createdBy',
|
|
81
|
+
'tags',
|
|
82
|
+
'attachment',
|
|
83
|
+
'referencesFileId',
|
|
84
|
+
'relatedTo',
|
|
85
|
+
'content',
|
|
86
|
+
'contentPresent',
|
|
87
|
+
'search',
|
|
88
|
+
'includeDeleted',
|
|
89
|
+
'includeUnlisted',
|
|
90
|
+
'createdAt',
|
|
91
|
+
'updatedAt',
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* Every param `GET /changes` defines. `since` is the resume cursor, which
|
|
95
|
+
* the server reads itself — see parseChangeParams().
|
|
96
|
+
*/
|
|
97
|
+
const CHANGE_PARAMS = [
|
|
98
|
+
'typeId',
|
|
99
|
+
'baseId',
|
|
100
|
+
'parentId',
|
|
101
|
+
'createdBySubject',
|
|
102
|
+
'createdByPrincipal',
|
|
103
|
+
'kind',
|
|
104
|
+
'include',
|
|
105
|
+
'includeUnlisted',
|
|
106
|
+
'since',
|
|
107
|
+
];
|
|
44
108
|
/**
|
|
45
109
|
* Strict positive-integer parse for a URL param — rejects "1abc", "2.7",
|
|
46
110
|
* "-5". Also serves the `:version` path params, which share this
|
|
@@ -48,18 +112,18 @@ const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
|
|
|
48
112
|
*/
|
|
49
113
|
export function parsePositiveInt(raw, label) {
|
|
50
114
|
if (!POSITIVE_INTEGER.test(raw))
|
|
51
|
-
throw new
|
|
115
|
+
throw new StackBadRequestError(`Invalid ${label}: "${raw}"`);
|
|
52
116
|
return parseInt(raw, 10);
|
|
53
117
|
}
|
|
54
118
|
function requireDate(raw, label) {
|
|
55
119
|
const d = parseDate(raw);
|
|
56
120
|
if (!d)
|
|
57
|
-
throw new
|
|
121
|
+
throw new StackBadRequestError(`Invalid ${label}: ${JSON.stringify(raw)}`);
|
|
58
122
|
return d;
|
|
59
123
|
}
|
|
60
124
|
function requireSortField(raw) {
|
|
61
125
|
if (typeof raw !== 'string' || !SORT_FIELDS.has(raw))
|
|
62
|
-
throw new
|
|
126
|
+
throw new StackBadRequestError(`Invalid sort field: ${JSON.stringify(raw)}`);
|
|
63
127
|
return raw;
|
|
64
128
|
}
|
|
65
129
|
/**
|
|
@@ -70,7 +134,7 @@ function requireSortField(raw) {
|
|
|
70
134
|
*/
|
|
71
135
|
function buildSort(field, contentField, direction) {
|
|
72
136
|
if (field !== undefined && field !== null && contentField !== undefined && contentField !== null)
|
|
73
|
-
throw new
|
|
137
|
+
throw new StackBadRequestError('A sort names either a native field or a content field, never both.');
|
|
74
138
|
const dir = direction === undefined || direction === null ? undefined : requireSortDirection(direction);
|
|
75
139
|
if (contentField !== undefined && contentField !== null) {
|
|
76
140
|
return {
|
|
@@ -85,12 +149,12 @@ function buildSort(field, contentField, direction) {
|
|
|
85
149
|
}
|
|
86
150
|
function requireSortDirection(raw) {
|
|
87
151
|
if (typeof raw !== 'string' || !SORT_DIRECTIONS.has(raw))
|
|
88
|
-
throw new
|
|
152
|
+
throw new StackBadRequestError(`Invalid sort direction: ${JSON.stringify(raw)}`);
|
|
89
153
|
return raw;
|
|
90
154
|
}
|
|
91
155
|
function requireString(raw, label) {
|
|
92
156
|
if (typeof raw !== 'string')
|
|
93
|
-
throw new
|
|
157
|
+
throw new StackBadRequestError(`Invalid ${label}: expected a string`);
|
|
94
158
|
return raw;
|
|
95
159
|
}
|
|
96
160
|
function requireStringOrArray(raw, label) {
|
|
@@ -98,16 +162,67 @@ function requireStringOrArray(raw, label) {
|
|
|
98
162
|
return raw;
|
|
99
163
|
if (Array.isArray(raw) && raw.every((v) => typeof v === 'string'))
|
|
100
164
|
return raw;
|
|
101
|
-
throw new
|
|
165
|
+
throw new StackBadRequestError(`Invalid ${label}: expected a string or array of strings`);
|
|
102
166
|
}
|
|
103
167
|
function requireStringArray(raw, label) {
|
|
104
168
|
if (!Array.isArray(raw) || !raw.every((v) => typeof v === 'string'))
|
|
105
|
-
throw new
|
|
169
|
+
throw new StackBadRequestError(`Invalid ${label}: expected an array of strings`);
|
|
106
170
|
return raw;
|
|
107
171
|
}
|
|
108
172
|
function requirePlainObject(raw, label) {
|
|
109
173
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
110
|
-
throw new
|
|
174
|
+
throw new StackBadRequestError(`Invalid ${label}: expected an object`);
|
|
175
|
+
return raw;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A plain object carrying only `keys`. An unrecognized key is refused
|
|
179
|
+
* rather than ignored, since ignoring it answers a different request than
|
|
180
|
+
* the one sent. See docs/spec/wire-format.md § Unrecognized input.
|
|
181
|
+
*/
|
|
182
|
+
function requireKnownKeys(raw, keys, label) {
|
|
183
|
+
const obj = requirePlainObject(raw, label);
|
|
184
|
+
const unknown = Object.keys(obj).filter((key) => !keys.includes(key));
|
|
185
|
+
if (unknown.length > 0)
|
|
186
|
+
throw new StackBadRequestError(`Unknown key${unknown.length > 1 ? 's' : ''} in ${label}: ${unknown.join(', ')}`);
|
|
187
|
+
return obj;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Params a filter repeats to name several values. Any other param names
|
|
191
|
+
* one value, so a repeat of it is refused rather than read as its first.
|
|
192
|
+
*/
|
|
193
|
+
const REPEATABLE_PARAMS = new Set([
|
|
194
|
+
'typeId',
|
|
195
|
+
'baseId',
|
|
196
|
+
'appId',
|
|
197
|
+
'createdBySubject',
|
|
198
|
+
'createdByPrincipal',
|
|
199
|
+
'tag',
|
|
200
|
+
'kind',
|
|
201
|
+
]);
|
|
202
|
+
/** The URL-param form of requireKnownKeys(), which also refuses a repeat. */
|
|
203
|
+
function requireKnownParams(url, names) {
|
|
204
|
+
const present = [...new Set(url.searchParams.keys())];
|
|
205
|
+
const unknown = present.filter((name) => !names.includes(name));
|
|
206
|
+
if (unknown.length > 0)
|
|
207
|
+
throw new StackBadRequestError(`Unknown query param${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
|
|
208
|
+
const repeated = present.filter((name) => !REPEATABLE_PARAMS.has(name) && url.searchParams.getAll(name).length > 1);
|
|
209
|
+
if (repeated.length > 0)
|
|
210
|
+
throw new StackBadRequestError(`Repeated query param${repeated.length > 1 ? 's' : ''}: ${repeated.join(', ')}`);
|
|
211
|
+
}
|
|
212
|
+
/** A boolean URL param: absent is false, and only `true`/`false` are values. */
|
|
213
|
+
function booleanParam(url, name) {
|
|
214
|
+
const value = url.searchParams.get(name);
|
|
215
|
+
if (value === null || value === 'false')
|
|
216
|
+
return false;
|
|
217
|
+
if (value === 'true')
|
|
218
|
+
return true;
|
|
219
|
+
throw new StackBadRequestError(`Invalid ${name}: expected true or false, got "${value}"`);
|
|
220
|
+
}
|
|
221
|
+
function optionalBoolean(raw, label) {
|
|
222
|
+
if (raw === undefined)
|
|
223
|
+
return undefined;
|
|
224
|
+
if (typeof raw !== 'boolean')
|
|
225
|
+
throw new StackBadRequestError(`Invalid ${label}: expected a boolean`);
|
|
111
226
|
return raw;
|
|
112
227
|
}
|
|
113
228
|
// -------------------------------------------------------
|
|
@@ -127,9 +242,9 @@ const PRESENT_AT_REFUSAL = 'presentAt applies migration functions, which are app
|
|
|
127
242
|
*/
|
|
128
243
|
function assertNoUntravelableFields(hasBaseId, hasPresentAt) {
|
|
129
244
|
if (hasBaseId)
|
|
130
|
-
throw new
|
|
245
|
+
throw new StackBadRequestError(BASE_ID_REFUSAL);
|
|
131
246
|
if (hasPresentAt)
|
|
132
|
-
throw new
|
|
247
|
+
throw new StackBadRequestError(PRESENT_AT_REFUSAL);
|
|
133
248
|
}
|
|
134
249
|
/**
|
|
135
250
|
* The client half of the same refusal, applied before a request is built
|
|
@@ -151,38 +266,40 @@ export function assertQueryTravels(query) {
|
|
|
151
266
|
// Relationship filter
|
|
152
267
|
// -------------------------------------------------------
|
|
153
268
|
/**
|
|
154
|
-
* Route a `filter.relatedTo.target` by
|
|
155
|
-
* non-emptiness of whichever fields the
|
|
156
|
-
* a
|
|
269
|
+
* Route a `filter.relatedTo.target` by kind. Core validates the
|
|
270
|
+
* non-emptiness of whichever fields the kind names, so this only rejects
|
|
271
|
+
* a kind it does not recognize.
|
|
157
272
|
*/
|
|
158
273
|
function parseRelatedToTarget(raw) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
274
|
+
const label = 'filter.relatedTo.target';
|
|
275
|
+
const kind = requirePlainObject(raw, label).kind;
|
|
276
|
+
if (typeof kind !== 'string' || !TARGET_KINDS.has(kind))
|
|
277
|
+
throw new StackBadRequestError(`Invalid filter.relatedTo.target.kind: ${JSON.stringify(kind)}`);
|
|
278
|
+
const t = requireKnownKeys(raw, TARGET_KEYS[kind], label);
|
|
279
|
+
if (t.kind === 'record') {
|
|
163
280
|
return {
|
|
164
|
-
|
|
281
|
+
kind: 'record',
|
|
165
282
|
recordId: requireString(t.recordId, 'filter.relatedTo.target.recordId'),
|
|
166
283
|
...(t.stackUrl !== undefined && {
|
|
167
284
|
stackUrl: requireString(t.stackUrl, 'filter.relatedTo.target.stackUrl'),
|
|
168
285
|
}),
|
|
169
286
|
};
|
|
170
287
|
}
|
|
171
|
-
if (t.
|
|
288
|
+
if (t.kind === 'entity') {
|
|
172
289
|
return {
|
|
173
|
-
|
|
290
|
+
kind: 'entity',
|
|
174
291
|
entityId: requireString(t.entityId, 'filter.relatedTo.target.entityId'),
|
|
175
292
|
};
|
|
176
293
|
}
|
|
177
294
|
return {
|
|
178
|
-
|
|
295
|
+
kind: 'external',
|
|
179
296
|
ns: requireString(t.ns, 'filter.relatedTo.target.ns'),
|
|
180
297
|
...(t.id !== undefined && { id: requireString(t.id, 'filter.relatedTo.target.id') }),
|
|
181
298
|
};
|
|
182
299
|
}
|
|
183
300
|
/** A label, a target, or both — the body form of the same filter. */
|
|
184
301
|
function parseRelatedToBody(raw) {
|
|
185
|
-
const r =
|
|
302
|
+
const r = requireKnownKeys(raw, ['label', 'target'], 'filter.relatedTo');
|
|
186
303
|
const label = r.label !== undefined ? requireString(r.label, 'filter.relatedTo.label') : undefined;
|
|
187
304
|
const target = r.target !== undefined ? parseRelatedToTarget(r.target) : undefined;
|
|
188
305
|
return {
|
|
@@ -206,26 +323,26 @@ function parseRelatedToParams(url) {
|
|
|
206
323
|
const hasId = url.searchParams.has('relatedToId');
|
|
207
324
|
const label = url.searchParams.get('relatedToLabel');
|
|
208
325
|
if ([hasRecord, hasEntity, hasNs].filter(Boolean).length > 1) {
|
|
209
|
-
throw new
|
|
326
|
+
throw new StackBadRequestError('relatedTo, relatedToEntity and relatedToNs name different target kinds and are mutually exclusive');
|
|
210
327
|
}
|
|
211
328
|
if (hasStack && !hasRecord)
|
|
212
|
-
throw new
|
|
329
|
+
throw new StackBadRequestError('relatedToStack is only valid alongside relatedTo');
|
|
213
330
|
if (hasId && !hasNs)
|
|
214
|
-
throw new
|
|
331
|
+
throw new StackBadRequestError('relatedToId is only valid alongside relatedToNs');
|
|
215
332
|
let target;
|
|
216
333
|
if (hasRecord) {
|
|
217
334
|
target = {
|
|
218
|
-
|
|
335
|
+
kind: 'record',
|
|
219
336
|
recordId: url.searchParams.get('relatedTo'),
|
|
220
337
|
...(hasStack && { stackUrl: url.searchParams.get('relatedToStack') }),
|
|
221
338
|
};
|
|
222
339
|
}
|
|
223
340
|
else if (hasEntity) {
|
|
224
|
-
target = {
|
|
341
|
+
target = { kind: 'entity', entityId: url.searchParams.get('relatedToEntity') };
|
|
225
342
|
}
|
|
226
343
|
else if (hasNs) {
|
|
227
344
|
target = {
|
|
228
|
-
|
|
345
|
+
kind: 'external',
|
|
229
346
|
ns: url.searchParams.get('relatedToNs'),
|
|
230
347
|
...(hasId && { id: url.searchParams.get('relatedToId') }),
|
|
231
348
|
};
|
|
@@ -237,9 +354,44 @@ function parseRelatedToParams(url) {
|
|
|
237
354
|
...(label && { label }),
|
|
238
355
|
};
|
|
239
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* The `attachment*` URL params; both halves together match one association.
|
|
359
|
+
* An empty value passes through rather than reading as absent, so it
|
|
360
|
+
* narrows to nothing instead of silently widening the query.
|
|
361
|
+
*/
|
|
362
|
+
function parseAttachmentParams(url) {
|
|
363
|
+
const label = url.searchParams.get('attachmentLabel');
|
|
364
|
+
const fileId = url.searchParams.get('attachmentFileId');
|
|
365
|
+
if (label === null && fileId === null)
|
|
366
|
+
return undefined;
|
|
367
|
+
return {
|
|
368
|
+
...(label !== null && { label }),
|
|
369
|
+
...(fileId !== null && { fileId }),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function parseAttachmentBody(raw) {
|
|
373
|
+
const a = requireKnownKeys(raw, ['label', 'fileId'], 'filter.attachment');
|
|
374
|
+
return {
|
|
375
|
+
...(a.label !== undefined && { label: requireString(a.label, 'filter.attachment.label') }),
|
|
376
|
+
...(a.fileId !== undefined && { fileId: requireString(a.fileId, 'filter.attachment.fileId') }),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
240
379
|
// -------------------------------------------------------
|
|
241
380
|
// GET /records
|
|
242
381
|
// -------------------------------------------------------
|
|
382
|
+
/** The author filter, spelled the same on `GET /records` and `GET /changes`. */
|
|
383
|
+
function parseCreatedByParams(url) {
|
|
384
|
+
const subjectIds = url.searchParams.getAll('createdBySubject');
|
|
385
|
+
const principalIds = url.searchParams.getAll('createdByPrincipal');
|
|
386
|
+
if (!subjectIds.length && !principalIds.length)
|
|
387
|
+
return undefined;
|
|
388
|
+
return {
|
|
389
|
+
...(subjectIds.length && { subjectId: subjectIds.length === 1 ? subjectIds[0] : subjectIds }),
|
|
390
|
+
...(principalIds.length && {
|
|
391
|
+
principalId: principalIds.length === 1 ? principalIds[0] : principalIds,
|
|
392
|
+
}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
243
395
|
/**
|
|
244
396
|
* Build a `StackQuery` from `GET /records` search params — the inverse of
|
|
245
397
|
* what `APIAdapter` encodes for a server that reaches no content.
|
|
@@ -249,6 +401,7 @@ function parseRelatedToParams(url) {
|
|
|
249
401
|
*/
|
|
250
402
|
export function parseQueryParams(url) {
|
|
251
403
|
assertNoUntravelableFields(url.searchParams.has('baseId'), url.searchParams.has('presentAt'));
|
|
404
|
+
requireKnownParams(url, RECORD_QUERY_PARAMS);
|
|
252
405
|
const filter = {};
|
|
253
406
|
const typeIds = url.searchParams.getAll('typeId');
|
|
254
407
|
if (typeIds.length)
|
|
@@ -259,21 +412,18 @@ export function parseQueryParams(url) {
|
|
|
259
412
|
const appIds = url.searchParams.getAll('appId');
|
|
260
413
|
if (appIds.length)
|
|
261
414
|
filter.appId = appIds.length === 1 ? appIds[0] : appIds;
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
filter.
|
|
265
|
-
const principalIds = url.searchParams.getAll('principalId');
|
|
266
|
-
if (principalIds.length)
|
|
267
|
-
filter.principalId = principalIds.length === 1 ? principalIds[0] : principalIds;
|
|
415
|
+
const createdBy = parseCreatedByParams(url);
|
|
416
|
+
if (createdBy)
|
|
417
|
+
filter.createdBy = createdBy;
|
|
268
418
|
const tags = url.searchParams.getAll('tag');
|
|
269
419
|
if (tags.length)
|
|
270
420
|
filter.tags = tags;
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
273
|
-
filter.
|
|
274
|
-
const
|
|
275
|
-
if (
|
|
276
|
-
filter.
|
|
421
|
+
const attachment = parseAttachmentParams(url);
|
|
422
|
+
if (attachment)
|
|
423
|
+
filter.attachment = attachment;
|
|
424
|
+
const referencesFileId = url.searchParams.get('referencesFileId');
|
|
425
|
+
if (referencesFileId)
|
|
426
|
+
filter.referencesFileId = referencesFileId;
|
|
277
427
|
const relatedTo = parseRelatedToParams(url);
|
|
278
428
|
if (relatedTo)
|
|
279
429
|
filter.relatedTo = relatedTo;
|
|
@@ -296,9 +446,9 @@ export function parseQueryParams(url) {
|
|
|
296
446
|
...(updatedAfter && { after: requireDate(updatedAfter, 'updatedAfter') }),
|
|
297
447
|
};
|
|
298
448
|
}
|
|
299
|
-
if (url
|
|
449
|
+
if (booleanParam(url, 'includeDeleted'))
|
|
300
450
|
filter.includeDeleted = true;
|
|
301
|
-
if (url
|
|
451
|
+
if (booleanParam(url, 'includeUnlisted'))
|
|
302
452
|
filter.includeUnlisted = true;
|
|
303
453
|
const query = {};
|
|
304
454
|
if (Object.keys(filter).length)
|
|
@@ -320,21 +470,26 @@ export function parseQueryParams(url) {
|
|
|
320
470
|
/** Validate a limit that already arrived as a JSON number. */
|
|
321
471
|
function parseLimitValue(raw) {
|
|
322
472
|
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0)
|
|
323
|
-
throw new
|
|
473
|
+
throw new StackBadRequestError(`Invalid limit: ${JSON.stringify(raw)}`);
|
|
324
474
|
return raw;
|
|
325
475
|
}
|
|
326
476
|
/**
|
|
327
477
|
* Build a `StackQuery` from a `POST /records/query` JSON body — the
|
|
328
478
|
* superset form, which additionally carries `filter.content`. Dates arrive
|
|
329
479
|
* as the ISO strings `JSON.stringify` made of them and are decoded back to
|
|
330
|
-
* `Date`.
|
|
480
|
+
* `Date`. `undefined` is the absent body; `null` or any other non-object
|
|
481
|
+
* is refused, so a server hands over no body as `undefined`.
|
|
482
|
+
* See docs/spec/wire-format.md § Records.
|
|
331
483
|
*/
|
|
332
484
|
export function parseQueryBody(raw) {
|
|
333
|
-
if (
|
|
485
|
+
if (raw === undefined)
|
|
334
486
|
return {};
|
|
335
|
-
const body = raw;
|
|
487
|
+
const body = requirePlainObject(raw, 'query body');
|
|
336
488
|
const f = body.filter !== undefined ? requirePlainObject(body.filter, 'filter') : undefined;
|
|
337
489
|
assertNoUntravelableFields(f !== undefined && 'baseId' in f, 'presentAt' in body);
|
|
490
|
+
requireKnownKeys(body, ['filter', 'sort', 'limit', 'cursor'], 'query body');
|
|
491
|
+
if (f)
|
|
492
|
+
requireKnownKeys(f, QUERY_BODY_FILTER_KEYS, 'filter');
|
|
338
493
|
const query = {};
|
|
339
494
|
if (f) {
|
|
340
495
|
const filter = {};
|
|
@@ -344,16 +499,23 @@ export function parseQueryBody(raw) {
|
|
|
344
499
|
filter.parentId = f.parentId === null ? null : requireString(f.parentId, 'filter.parentId');
|
|
345
500
|
if (f.appId !== undefined)
|
|
346
501
|
filter.appId = requireStringOrArray(f.appId, 'filter.appId');
|
|
347
|
-
if (f.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
502
|
+
if (f.createdBy !== undefined) {
|
|
503
|
+
const by = requireKnownKeys(f.createdBy, ['subjectId', 'principalId'], 'filter.createdBy');
|
|
504
|
+
filter.createdBy = {
|
|
505
|
+
...(by.subjectId !== undefined && {
|
|
506
|
+
subjectId: requireStringOrArray(by.subjectId, 'filter.createdBy.subjectId'),
|
|
507
|
+
}),
|
|
508
|
+
...(by.principalId !== undefined && {
|
|
509
|
+
principalId: requireStringOrArray(by.principalId, 'filter.createdBy.principalId'),
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
351
513
|
if (f.tags !== undefined)
|
|
352
514
|
filter.tags = requireStringArray(f.tags, 'filter.tags');
|
|
353
|
-
if (f.
|
|
354
|
-
filter.
|
|
355
|
-
if (f.
|
|
356
|
-
filter.
|
|
515
|
+
if (f.attachment !== undefined)
|
|
516
|
+
filter.attachment = parseAttachmentBody(f.attachment);
|
|
517
|
+
if (f.referencesFileId !== undefined)
|
|
518
|
+
filter.referencesFileId = requireString(f.referencesFileId, 'filter.referencesFileId');
|
|
357
519
|
if (f.relatedTo !== undefined)
|
|
358
520
|
filter.relatedTo = parseRelatedToBody(f.relatedTo);
|
|
359
521
|
if (f.content !== undefined)
|
|
@@ -362,19 +524,19 @@ export function parseQueryBody(raw) {
|
|
|
362
524
|
filter.contentPresent = requireStringArray(f.contentPresent, 'filter.contentPresent');
|
|
363
525
|
if (f.search !== undefined)
|
|
364
526
|
filter.search = requireString(f.search, 'filter.search');
|
|
365
|
-
if (f.includeDeleted)
|
|
527
|
+
if (optionalBoolean(f.includeDeleted, 'filter.includeDeleted'))
|
|
366
528
|
filter.includeDeleted = true;
|
|
367
|
-
if (f.includeUnlisted)
|
|
529
|
+
if (optionalBoolean(f.includeUnlisted, 'filter.includeUnlisted'))
|
|
368
530
|
filter.includeUnlisted = true;
|
|
369
|
-
if (f.createdAt) {
|
|
370
|
-
const r =
|
|
531
|
+
if (f.createdAt !== undefined) {
|
|
532
|
+
const r = requireKnownKeys(f.createdAt, ['before', 'after'], 'filter.createdAt');
|
|
371
533
|
filter.createdAt = {
|
|
372
534
|
...(r.before !== undefined && { before: requireDate(r.before, 'filter.createdAt.before') }),
|
|
373
535
|
...(r.after !== undefined && { after: requireDate(r.after, 'filter.createdAt.after') }),
|
|
374
536
|
};
|
|
375
537
|
}
|
|
376
|
-
if (f.updatedAt) {
|
|
377
|
-
const r =
|
|
538
|
+
if (f.updatedAt !== undefined) {
|
|
539
|
+
const r = requireKnownKeys(f.updatedAt, ['before', 'after'], 'filter.updatedAt');
|
|
378
540
|
filter.updatedAt = {
|
|
379
541
|
...(r.before !== undefined && { before: requireDate(r.before, 'filter.updatedAt.before') }),
|
|
380
542
|
...(r.after !== undefined && { after: requireDate(r.after, 'filter.updatedAt.after') }),
|
|
@@ -382,17 +544,17 @@ export function parseQueryBody(raw) {
|
|
|
382
544
|
}
|
|
383
545
|
query.filter = filter;
|
|
384
546
|
}
|
|
385
|
-
if (body.sort) {
|
|
386
|
-
const s =
|
|
547
|
+
if (body.sort !== undefined) {
|
|
548
|
+
const s = requireKnownKeys(body.sort, ['field', 'contentField', 'direction'], 'sort');
|
|
387
549
|
const sort = buildSort(s.field, s.contentField, s.direction);
|
|
388
550
|
if (!sort)
|
|
389
|
-
throw new
|
|
551
|
+
throw new StackBadRequestError('Invalid sort: expected a field or a contentField.');
|
|
390
552
|
query.sort = sort;
|
|
391
553
|
}
|
|
392
554
|
if (body.limit !== undefined)
|
|
393
555
|
query.limit = parseLimitValue(body.limit);
|
|
394
|
-
if (
|
|
395
|
-
query.cursor = body.cursor;
|
|
556
|
+
if (body.cursor !== undefined)
|
|
557
|
+
query.cursor = requireString(body.cursor, 'cursor');
|
|
396
558
|
return query;
|
|
397
559
|
}
|
|
398
560
|
/**
|
|
@@ -401,31 +563,35 @@ export function parseQueryBody(raw) {
|
|
|
401
563
|
* the server's own resumption machinery rather than request encoding.
|
|
402
564
|
*/
|
|
403
565
|
export function parseChangeParams(url) {
|
|
566
|
+
requireKnownParams(url, CHANGE_PARAMS);
|
|
404
567
|
const filter = {};
|
|
405
568
|
const typeIds = url.searchParams.getAll('typeId');
|
|
406
569
|
if (typeIds.length)
|
|
407
570
|
filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds;
|
|
571
|
+
const baseIds = url.searchParams.getAll('baseId');
|
|
572
|
+
if (baseIds.length)
|
|
573
|
+
filter.baseId = baseIds.length === 1 ? baseIds[0] : baseIds;
|
|
408
574
|
const parentId = url.searchParams.get('parentId');
|
|
409
575
|
if (parentId !== null)
|
|
410
576
|
filter.parentId = parentId === 'null' ? null : parentId;
|
|
411
|
-
const
|
|
412
|
-
if (
|
|
413
|
-
filter.
|
|
577
|
+
const createdBy = parseCreatedByParams(url);
|
|
578
|
+
if (createdBy)
|
|
579
|
+
filter.createdBy = createdBy;
|
|
414
580
|
const kinds = url.searchParams.getAll('kind');
|
|
415
581
|
if (kinds.length) {
|
|
416
582
|
for (const kind of kinds) {
|
|
417
583
|
if (!CHANGE_KINDS.has(kind))
|
|
418
|
-
throw new
|
|
584
|
+
throw new StackBadRequestError(`Invalid kind: "${kind}"`);
|
|
419
585
|
}
|
|
420
586
|
filter.kinds = kinds;
|
|
421
587
|
}
|
|
422
588
|
const include = url.searchParams.get('include');
|
|
423
589
|
if (include !== null && include !== 'record')
|
|
424
|
-
throw new
|
|
590
|
+
throw new StackBadRequestError(`Invalid include: "${include}"`);
|
|
425
591
|
return {
|
|
426
592
|
filter,
|
|
427
593
|
includeRecords: include === 'record',
|
|
428
|
-
includeUnlisted: url
|
|
594
|
+
includeUnlisted: booleanParam(url, 'includeUnlisted'),
|
|
429
595
|
};
|
|
430
596
|
}
|
|
431
597
|
/**
|
|
@@ -434,20 +600,63 @@ export function parseChangeParams(url) {
|
|
|
434
600
|
* omitting `limit` reads the whole log by contract, so supplying a page
|
|
435
601
|
* size here would truncate exactly the caller that omitted it.
|
|
436
602
|
*
|
|
437
|
-
* `
|
|
603
|
+
* `afterSeq` is a journal `seq` — a dense per-record integer, never the
|
|
438
604
|
* change feed's opaque cursor. See docs/spec/wire-format.md § Journal.
|
|
439
605
|
*/
|
|
440
606
|
export function parseJournalParams(url) {
|
|
607
|
+
requireKnownParams(url, ['afterSeq', 'limit']);
|
|
441
608
|
const query = {};
|
|
442
|
-
const
|
|
443
|
-
if (
|
|
444
|
-
query.
|
|
609
|
+
const afterSeq = url.searchParams.get('afterSeq');
|
|
610
|
+
if (afterSeq !== null)
|
|
611
|
+
query.afterSeq = parsePositiveInt(afterSeq, 'afterSeq');
|
|
445
612
|
const limit = url.searchParams.get('limit');
|
|
446
613
|
if (limit !== null)
|
|
447
614
|
query.limit = parsePositiveInt(limit, 'limit');
|
|
448
615
|
return query;
|
|
449
616
|
}
|
|
450
617
|
// -------------------------------------------------------
|
|
618
|
+
// DELETE /records/:id
|
|
619
|
+
// -------------------------------------------------------
|
|
620
|
+
/**
|
|
621
|
+
* Parse `DELETE /records/:id`'s query params into the `purge` flag. A
|
|
622
|
+
* misspelled or non-boolean `purge` is refused rather than read as a soft
|
|
623
|
+
* delete. See docs/spec/wire-format.md § Records.
|
|
624
|
+
*/
|
|
625
|
+
export function parseDeleteParams(url) {
|
|
626
|
+
requireKnownParams(url, ['purge']);
|
|
627
|
+
return { purge: booleanParam(url, 'purge') };
|
|
628
|
+
}
|
|
629
|
+
/** Parse `GET /attachments/:fileId`'s query params. See docs/spec/wire-format.md § Download. */
|
|
630
|
+
export function parseDownloadParams(url) {
|
|
631
|
+
requireKnownParams(url, ['contentType', 'filename']);
|
|
632
|
+
const contentType = url.searchParams.get('contentType');
|
|
633
|
+
const filename = url.searchParams.get('filename');
|
|
634
|
+
return {
|
|
635
|
+
...(contentType !== null && { contentTypeParam: contentType }),
|
|
636
|
+
...(filename !== null && { filenameParam: filename }),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Parse `GET /records/:id/associations`' query params. An unrecognized
|
|
641
|
+
* `kind` is refused, since reading it as absent would answer with every
|
|
642
|
+
* kind. `kind` repeats on `GET /changes` but names one value here, so a
|
|
643
|
+
* repeat is refused rather than read as its first.
|
|
644
|
+
* See docs/spec/wire-format.md § Associations.
|
|
645
|
+
*/
|
|
646
|
+
export function parseAssociationParams(url) {
|
|
647
|
+
requireKnownParams(url, ['kind', 'label']);
|
|
648
|
+
if (url.searchParams.getAll('kind').length > 1)
|
|
649
|
+
throw new StackBadRequestError('Repeated query param: kind');
|
|
650
|
+
const kind = url.searchParams.get('kind');
|
|
651
|
+
if (kind !== null && !ASSOCIATION_KINDS.has(kind))
|
|
652
|
+
throw new StackBadRequestError(`Invalid kind: "${kind}"`);
|
|
653
|
+
const label = url.searchParams.get('label');
|
|
654
|
+
return {
|
|
655
|
+
...(kind !== null && { kind: kind }),
|
|
656
|
+
...(label !== null && { label }),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
// -------------------------------------------------------
|
|
451
660
|
// Headers
|
|
452
661
|
// -------------------------------------------------------
|
|
453
662
|
/**
|