@jskit-ai/json-rest-api-core 0.1.1
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.
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
packageVersion: 1,
|
|
3
|
+
packageId: "@jskit-ai/json-rest-api-core",
|
|
4
|
+
version: "0.1.1",
|
|
5
|
+
kind: "runtime",
|
|
6
|
+
description: "Shared internal json-rest-api host runtime for JSKIT server packages.",
|
|
7
|
+
dependsOn: [
|
|
8
|
+
"@jskit-ai/database-runtime",
|
|
9
|
+
"@jskit-ai/kernel"
|
|
10
|
+
],
|
|
11
|
+
capabilities: {
|
|
12
|
+
provides: [
|
|
13
|
+
"json-rest-api.core"
|
|
14
|
+
],
|
|
15
|
+
requires: [
|
|
16
|
+
"runtime.database"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
runtime: {
|
|
20
|
+
server: {
|
|
21
|
+
providers: [
|
|
22
|
+
{
|
|
23
|
+
entrypoint: "src/server/JsonRestApiCoreServiceProvider.js",
|
|
24
|
+
export: "JsonRestApiCoreServiceProvider"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
client: {
|
|
29
|
+
providers: []
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
metadata: {
|
|
33
|
+
apiSummary: {
|
|
34
|
+
surfaces: [
|
|
35
|
+
{
|
|
36
|
+
subpath: "./server",
|
|
37
|
+
summary: "Exports the shared internal json-rest-api host token and host registration helpers."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
containerTokens: {
|
|
41
|
+
server: [
|
|
42
|
+
"internal.json-rest-api"
|
|
43
|
+
],
|
|
44
|
+
client: []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
mutations: {
|
|
49
|
+
dependencies: {
|
|
50
|
+
runtime: {},
|
|
51
|
+
dev: {}
|
|
52
|
+
},
|
|
53
|
+
packageJson: {
|
|
54
|
+
scripts: {}
|
|
55
|
+
},
|
|
56
|
+
procfile: {},
|
|
57
|
+
files: [],
|
|
58
|
+
text: []
|
|
59
|
+
}
|
|
60
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/json-rest-api-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server/jsonRestApiHost": "./src/server/jsonRestApiHost.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"hooked-api": "1.x.x",
|
|
13
|
+
"json-rest-api": "1.x.x",
|
|
14
|
+
"@jskit-ai/kernel": "0.1.56"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { registerJsonRestApiHost } from "./jsonRestApiHost.js";
|
|
2
|
+
|
|
3
|
+
class JsonRestApiCoreServiceProvider {
|
|
4
|
+
static id = "json-rest-api.core";
|
|
5
|
+
|
|
6
|
+
static dependsOn = ["runtime.database"];
|
|
7
|
+
|
|
8
|
+
async boot(app) {
|
|
9
|
+
await registerJsonRestApiHost(app);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { JsonRestApiCoreServiceProvider };
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { Api } from "hooked-api";
|
|
2
|
+
import { AutoFilterPlugin, RestApiKnexPlugin, RestApiPlugin } from "json-rest-api";
|
|
3
|
+
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
+
|
|
5
|
+
const INTERNAL_JSON_REST_API = "internal.json-rest-api";
|
|
6
|
+
|
|
7
|
+
const JSON_REST_AUTOFILTER_PRESETS = Object.freeze({
|
|
8
|
+
public: Object.freeze([]),
|
|
9
|
+
workspace: Object.freeze([
|
|
10
|
+
Object.freeze({
|
|
11
|
+
field: "workspaceId",
|
|
12
|
+
resolver: "workspace"
|
|
13
|
+
})
|
|
14
|
+
]),
|
|
15
|
+
user: Object.freeze([
|
|
16
|
+
Object.freeze({
|
|
17
|
+
field: "userId",
|
|
18
|
+
resolver: "user"
|
|
19
|
+
})
|
|
20
|
+
]),
|
|
21
|
+
workspace_user: Object.freeze([
|
|
22
|
+
Object.freeze({
|
|
23
|
+
field: "workspaceId",
|
|
24
|
+
resolver: "workspace"
|
|
25
|
+
}),
|
|
26
|
+
Object.freeze({
|
|
27
|
+
field: "userId",
|
|
28
|
+
resolver: "user"
|
|
29
|
+
})
|
|
30
|
+
])
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
|
|
34
|
+
"cursor",
|
|
35
|
+
"limit",
|
|
36
|
+
"include",
|
|
37
|
+
"sort",
|
|
38
|
+
"fields"
|
|
39
|
+
]));
|
|
40
|
+
|
|
41
|
+
function isPlainJsonRestObject(value) {
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const prototype = Object.getPrototypeOf(value);
|
|
47
|
+
return prototype === Object.prototype || prototype === null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
return value.map((entry) => cloneJsonRestResourceValue(entry, { writeSerializers }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isPlainJsonRestObject(value)) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const next = {};
|
|
60
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
61
|
+
next[key] = cloneJsonRestResourceValue(entry, { writeSerializers });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isPlainJsonRestObject(next.storage)) {
|
|
65
|
+
const serializerKey = normalizeJsonRestText(next.storage.writeSerializer).toLowerCase();
|
|
66
|
+
if (serializerKey) {
|
|
67
|
+
const serializer = writeSerializers[serializerKey];
|
|
68
|
+
if (typeof serializer !== "function") {
|
|
69
|
+
throw new Error(`Unsupported json-rest-api write serializer: ${JSON.stringify(serializerKey)}.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
next.storage = {
|
|
73
|
+
...next.storage,
|
|
74
|
+
serialize: serializer
|
|
75
|
+
};
|
|
76
|
+
delete next.storage.writeSerializer;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function addResourceIfMissing(api, scopeName, resourceConfig) {
|
|
84
|
+
if (api?.resources?.[scopeName]) {
|
|
85
|
+
return api.resources[scopeName];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await api.addResource(scopeName, resourceConfig);
|
|
89
|
+
return api.resources[scopeName];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeScopeValue(value) {
|
|
93
|
+
if (value == null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof value === "string") {
|
|
98
|
+
const normalized = value.trim();
|
|
99
|
+
return normalized || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof value === "number") {
|
|
103
|
+
return Number.isFinite(value) ? String(value) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof value === "bigint") {
|
|
107
|
+
return String(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeJsonRestText(value, { fallback = "" } = {}) {
|
|
114
|
+
const normalized = String(value || "").trim();
|
|
115
|
+
return normalized || fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeJsonRestObject(value) {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeJsonRestList(value) {
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
return value
|
|
129
|
+
.map((entry) => normalizeJsonRestText(entry))
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const normalized = normalizeJsonRestText(value);
|
|
134
|
+
if (!normalized) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return normalized
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((entry) => normalizeJsonRestText(entry))
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildJsonRestQueryParams(resourceType = "", query = {}, { include = undefined } = {}) {
|
|
145
|
+
const normalizedResourceType = normalizeJsonRestText(resourceType);
|
|
146
|
+
const source = normalizeJsonRestObject(query);
|
|
147
|
+
const filters = {};
|
|
148
|
+
|
|
149
|
+
for (const [rawKey, rawValue] of Object.entries(source)) {
|
|
150
|
+
const key = normalizeJsonRestText(rawKey);
|
|
151
|
+
if (!key || JSON_REST_RESERVED_QUERY_KEYS.has(key)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const normalizedValue = normalizeJsonRestText(rawValue);
|
|
156
|
+
if (!normalizedValue) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
filters[key] = normalizedValue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const queryParams = {};
|
|
164
|
+
|
|
165
|
+
if (Object.keys(filters).length > 0) {
|
|
166
|
+
queryParams.filters = filters;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const includeValues = normalizeJsonRestList(include === undefined ? source.include : include);
|
|
170
|
+
if (includeValues.length > 0) {
|
|
171
|
+
queryParams.include = includeValues;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sortValues = normalizeJsonRestList(source.sort);
|
|
175
|
+
if (sortValues.length > 0) {
|
|
176
|
+
queryParams.sort = sortValues;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cursor = normalizeJsonRestText(source.cursor);
|
|
180
|
+
const limitText = normalizeJsonRestText(source.limit);
|
|
181
|
+
if (cursor || limitText) {
|
|
182
|
+
queryParams.page = {
|
|
183
|
+
...(cursor ? { after: cursor } : {}),
|
|
184
|
+
...(limitText ? { size: limitText } : {})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const fields = normalizeJsonRestText(source.fields);
|
|
189
|
+
if (normalizedResourceType && fields) {
|
|
190
|
+
queryParams.fields = {
|
|
191
|
+
[normalizedResourceType]: fields
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return queryParams;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createJsonApiInputRecord(resourceType = "", attributes = {}, { id = null, relationships = null } = {}) {
|
|
199
|
+
const normalizedRelationships = normalizeJsonRestObject(relationships);
|
|
200
|
+
return {
|
|
201
|
+
data: {
|
|
202
|
+
type: normalizeJsonRestText(resourceType),
|
|
203
|
+
...(id == null ? {} : { id: String(id) }),
|
|
204
|
+
attributes: {
|
|
205
|
+
...normalizeJsonRestObject(attributes)
|
|
206
|
+
},
|
|
207
|
+
...(Object.keys(normalizedRelationships).length < 1 ? {} : { relationships: normalizedRelationships })
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function createJsonApiRelationship(resourceType = "", id = null) {
|
|
213
|
+
if (id == null) {
|
|
214
|
+
return {
|
|
215
|
+
data: null
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
data: {
|
|
221
|
+
type: normalizeJsonRestText(resourceType),
|
|
222
|
+
id: String(id)
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createJsonRestResourceScopeOptions(resource = {}, { writeSerializers = {}, normalizeId = null } = {}) {
|
|
228
|
+
const scopeOptions = cloneJsonRestResourceValue(resource, {
|
|
229
|
+
writeSerializers: normalizeJsonRestObject(writeSerializers)
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (typeof normalizeId === "function") {
|
|
233
|
+
scopeOptions.normalizeId = normalizeId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return scopeOptions;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeJsonApiResourceObject(resource = {}) {
|
|
240
|
+
const normalizedResource = normalizeJsonRestObject(resource);
|
|
241
|
+
return {
|
|
242
|
+
type: normalizeJsonRestText(normalizedResource.type),
|
|
243
|
+
id: normalizedResource.id == null ? null : String(normalizedResource.id),
|
|
244
|
+
attributes: normalizeJsonRestObject(normalizedResource.attributes),
|
|
245
|
+
relationships: normalizeJsonRestObject(normalizedResource.relationships)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildJsonApiIncludedIndex(payload = {}) {
|
|
250
|
+
const included = Array.isArray(payload?.included) ? payload.included : [];
|
|
251
|
+
const index = new Map();
|
|
252
|
+
|
|
253
|
+
for (const entry of included) {
|
|
254
|
+
const normalizedEntry = normalizeJsonApiResourceObject(entry);
|
|
255
|
+
if (!normalizedEntry.type || !normalizedEntry.id) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
index.set(`${normalizedEntry.type}:${normalizedEntry.id}`, normalizedEntry);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return index;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function simplifyJsonApiRelationshipData(data, { includedIndex = null, seen = null } = {}) {
|
|
266
|
+
if (Array.isArray(data)) {
|
|
267
|
+
return data
|
|
268
|
+
.map((entry) => simplifyJsonApiRelationshipData(entry, { includedIndex, seen }))
|
|
269
|
+
.filter((entry) => entry != null);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (data == null) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const normalizedReference = normalizeJsonApiResourceObject(data);
|
|
277
|
+
if (!normalizedReference.id) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const referenceKey =
|
|
282
|
+
normalizedReference.type && normalizedReference.id
|
|
283
|
+
? `${normalizedReference.type}:${normalizedReference.id}`
|
|
284
|
+
: "";
|
|
285
|
+
const nextSeen = seen instanceof Set ? new Set(seen) : new Set();
|
|
286
|
+
|
|
287
|
+
if (referenceKey) {
|
|
288
|
+
if (nextSeen.has(referenceKey)) {
|
|
289
|
+
return {
|
|
290
|
+
id: normalizedReference.id,
|
|
291
|
+
...(normalizedReference.type ? { type: normalizedReference.type } : {})
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
nextSeen.add(referenceKey);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (referenceKey && includedIndex instanceof Map && includedIndex.has(referenceKey)) {
|
|
298
|
+
return simplifyJsonApiResourceObject(includedIndex.get(referenceKey), {
|
|
299
|
+
includedIndex,
|
|
300
|
+
seen: nextSeen
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
id: normalizedReference.id,
|
|
306
|
+
...(normalizedReference.type ? { type: normalizedReference.type } : {})
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function simplifyJsonApiResourceObject(resource = {}, { includedIndex = null, seen = null } = {}) {
|
|
311
|
+
const normalizedResource = normalizeJsonApiResourceObject(resource);
|
|
312
|
+
const resourceKey =
|
|
313
|
+
normalizedResource.type && normalizedResource.id
|
|
314
|
+
? `${normalizedResource.type}:${normalizedResource.id}`
|
|
315
|
+
: "";
|
|
316
|
+
const nextSeen = seen instanceof Set ? new Set(seen) : new Set();
|
|
317
|
+
|
|
318
|
+
if (resourceKey) {
|
|
319
|
+
nextSeen.add(resourceKey);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const simplified = {
|
|
323
|
+
...(normalizedResource.id == null ? {} : { id: normalizedResource.id }),
|
|
324
|
+
...normalizedResource.attributes
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
for (const [relationshipKey, relationshipValue] of Object.entries(normalizedResource.relationships)) {
|
|
328
|
+
if (!relationshipKey || !relationshipValue || !Object.hasOwn(relationshipValue, "data")) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
simplified[relationshipKey] = simplifyJsonApiRelationshipData(relationshipValue.data, {
|
|
333
|
+
includedIndex,
|
|
334
|
+
seen: nextSeen
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return simplified;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function simplifyJsonApiDocument(payload = {}) {
|
|
342
|
+
const source = normalizeJsonRestObject(payload);
|
|
343
|
+
const includedIndex = buildJsonApiIncludedIndex(source);
|
|
344
|
+
|
|
345
|
+
if (Array.isArray(source.data)) {
|
|
346
|
+
return source.data.map((entry) => simplifyJsonApiResourceObject(entry, { includedIndex }));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (source.data && typeof source.data === "object") {
|
|
350
|
+
return simplifyJsonApiResourceObject(source.data, { includedIndex });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (Object.hasOwn(source, "data") && source.data == null) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)) {
|
|
358
|
+
return source.meta;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return payload;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function createJsonRestContext(context = null) {
|
|
365
|
+
if (!context || typeof context !== "object" || Array.isArray(context)) {
|
|
366
|
+
return {};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const nextContext = {
|
|
370
|
+
...context
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
if (context.visibilityContext && typeof context.visibilityContext === "object" && !Array.isArray(context.visibilityContext)) {
|
|
374
|
+
nextContext.visibilityContext = {
|
|
375
|
+
...context.visibilityContext
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (context.scopeValues && typeof context.scopeValues === "object" && !Array.isArray(context.scopeValues)) {
|
|
380
|
+
nextContext.scopeValues = {
|
|
381
|
+
...context.scopeValues
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return nextContext;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function resolveWorkspaceScopeValue(context = null) {
|
|
389
|
+
const explicitScopeValue = normalizeScopeValue(context?.scopeValues?.workspaceId);
|
|
390
|
+
if (explicitScopeValue) {
|
|
391
|
+
return explicitScopeValue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return normalizeScopeValue(context?.visibilityContext?.scopeOwnerId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function resolveUserScopeValue(context = null) {
|
|
398
|
+
const explicitScopeValue = normalizeScopeValue(context?.scopeValues?.userId);
|
|
399
|
+
if (explicitScopeValue) {
|
|
400
|
+
return explicitScopeValue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return normalizeScopeValue(context?.visibilityContext?.userId);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isJsonRestResourceMissingError(error = null) {
|
|
407
|
+
return normalizeJsonRestText(error?.code) === "REST_API_RESOURCE" &&
|
|
408
|
+
normalizeJsonRestText(error?.subtype) === "not_found";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function returnNullWhenJsonRestResourceMissing(run) {
|
|
412
|
+
if (typeof run !== "function") {
|
|
413
|
+
throw new TypeError("returnNullWhenJsonRestResourceMissing requires run function.");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
return await run();
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (isJsonRestResourceMissingError(error)) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function createJsonRestApiHost({ knex }) {
|
|
428
|
+
if (typeof knex !== "function") {
|
|
429
|
+
throw new TypeError("createJsonRestApiHost requires knex.");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const api = new Api({
|
|
433
|
+
name: "jskit-internal-json-rest-api",
|
|
434
|
+
logLevel: "error"
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await api.use(RestApiPlugin, {
|
|
438
|
+
simplifiedApi: true,
|
|
439
|
+
simplifiedTransport: false,
|
|
440
|
+
returnRecordApi: {
|
|
441
|
+
post: "full",
|
|
442
|
+
put: "full",
|
|
443
|
+
patch: "full"
|
|
444
|
+
},
|
|
445
|
+
normalizeId: normalizeRecordId
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await api.use(RestApiKnexPlugin, { knex });
|
|
449
|
+
await api.use(AutoFilterPlugin, {
|
|
450
|
+
resolvers: {
|
|
451
|
+
workspace: ({ context }) => resolveWorkspaceScopeValue(context),
|
|
452
|
+
user: ({ context }) => resolveUserScopeValue(context)
|
|
453
|
+
},
|
|
454
|
+
presets: JSON_REST_AUTOFILTER_PRESETS
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return api;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function registerJsonRestApiHost(app) {
|
|
461
|
+
if (!app || typeof app.instance !== "function" || typeof app.make !== "function" || typeof app.has !== "function") {
|
|
462
|
+
throw new Error("registerJsonRestApiHost requires application instance()/make()/has().");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (app.has(INTERNAL_JSON_REST_API)) {
|
|
466
|
+
return app.make(INTERNAL_JSON_REST_API);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const knex = app.make("jskit.database.knex");
|
|
470
|
+
const api = await createJsonRestApiHost({ knex });
|
|
471
|
+
app.instance(INTERNAL_JSON_REST_API, api);
|
|
472
|
+
return api;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export {
|
|
476
|
+
INTERNAL_JSON_REST_API,
|
|
477
|
+
JSON_REST_AUTOFILTER_PRESETS,
|
|
478
|
+
addResourceIfMissing,
|
|
479
|
+
buildJsonRestQueryParams,
|
|
480
|
+
createJsonApiInputRecord,
|
|
481
|
+
createJsonApiRelationship,
|
|
482
|
+
createJsonRestResourceScopeOptions,
|
|
483
|
+
createJsonRestContext,
|
|
484
|
+
isJsonRestResourceMissingError,
|
|
485
|
+
returnNullWhenJsonRestResourceMissing,
|
|
486
|
+
resolveWorkspaceScopeValue,
|
|
487
|
+
resolveUserScopeValue,
|
|
488
|
+
simplifyJsonApiDocument,
|
|
489
|
+
createJsonRestApiHost,
|
|
490
|
+
registerJsonRestApiHost
|
|
491
|
+
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
INTERNAL_JSON_REST_API,
|
|
8
|
+
addResourceIfMissing,
|
|
9
|
+
buildJsonRestQueryParams,
|
|
10
|
+
createJsonApiInputRecord,
|
|
11
|
+
createJsonApiRelationship,
|
|
12
|
+
createJsonRestResourceScopeOptions,
|
|
13
|
+
createJsonRestContext,
|
|
14
|
+
createJsonRestApiHost,
|
|
15
|
+
isJsonRestResourceMissingError,
|
|
16
|
+
registerJsonRestApiHost,
|
|
17
|
+
returnNullWhenJsonRestResourceMissing,
|
|
18
|
+
resolveWorkspaceScopeValue,
|
|
19
|
+
resolveUserScopeValue,
|
|
20
|
+
simplifyJsonApiDocument
|
|
21
|
+
} from "../src/server/jsonRestApiHost.js";
|
|
22
|
+
import { JsonRestApiCoreServiceProvider } from "../src/server/JsonRestApiCoreServiceProvider.js";
|
|
23
|
+
|
|
24
|
+
test("package exports include explicit server jsonRestApiHost entrypoint only", async () => {
|
|
25
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
26
|
+
const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
|
|
27
|
+
assert.equal(exportsMap["./server/jsonRestApiHost"], "./src/server/jsonRestApiHost.js");
|
|
28
|
+
assert.equal(exportsMap["./server"], undefined);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("server entrypoint exports shared host helpers", () => {
|
|
32
|
+
assert.equal(INTERNAL_JSON_REST_API, "internal.json-rest-api");
|
|
33
|
+
assert.equal(typeof addResourceIfMissing, "function");
|
|
34
|
+
assert.equal(typeof buildJsonRestQueryParams, "function");
|
|
35
|
+
assert.equal(typeof createJsonApiInputRecord, "function");
|
|
36
|
+
assert.equal(typeof createJsonApiRelationship, "function");
|
|
37
|
+
assert.equal(typeof createJsonRestResourceScopeOptions, "function");
|
|
38
|
+
assert.equal(typeof createJsonRestContext, "function");
|
|
39
|
+
assert.equal(typeof createJsonRestApiHost, "function");
|
|
40
|
+
assert.equal(typeof isJsonRestResourceMissingError, "function");
|
|
41
|
+
assert.equal(typeof registerJsonRestApiHost, "function");
|
|
42
|
+
assert.equal(typeof returnNullWhenJsonRestResourceMissing, "function");
|
|
43
|
+
assert.equal(typeof resolveWorkspaceScopeValue, "function");
|
|
44
|
+
assert.equal(typeof resolveUserScopeValue, "function");
|
|
45
|
+
assert.equal(typeof simplifyJsonApiDocument, "function");
|
|
46
|
+
assert.equal(typeof JsonRestApiCoreServiceProvider, "function");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("createJsonRestContext returns a mutable clone for frozen JSKIT execution context", () => {
|
|
50
|
+
const source = Object.freeze({
|
|
51
|
+
visibilityContext: Object.freeze({
|
|
52
|
+
visibility: "workspace",
|
|
53
|
+
scopeOwnerId: "workspace-7",
|
|
54
|
+
userId: "user-2"
|
|
55
|
+
}),
|
|
56
|
+
scopeValues: Object.freeze({
|
|
57
|
+
workspaceId: "workspace-explicit"
|
|
58
|
+
}),
|
|
59
|
+
requestMeta: Object.freeze({
|
|
60
|
+
traceId: "trace-1"
|
|
61
|
+
})
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = createJsonRestContext(source);
|
|
65
|
+
|
|
66
|
+
assert.notEqual(result, source);
|
|
67
|
+
assert.notEqual(result.visibilityContext, source.visibilityContext);
|
|
68
|
+
assert.notEqual(result.scopeValues, source.scopeValues);
|
|
69
|
+
assert.equal(result.requestMeta, source.requestMeta);
|
|
70
|
+
|
|
71
|
+
result.method = "query";
|
|
72
|
+
result.scopeValues.userId = "user-2";
|
|
73
|
+
|
|
74
|
+
assert.equal(result.method, "query");
|
|
75
|
+
assert.equal(result.scopeValues.userId, "user-2");
|
|
76
|
+
assert.equal(source.scopeValues.userId, undefined);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("createJsonRestContext returns an empty mutable object when source context is absent", () => {
|
|
80
|
+
const result = createJsonRestContext(null);
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(result, {});
|
|
83
|
+
result.method = "query";
|
|
84
|
+
assert.equal(result.method, "query");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("createJsonRestApiHost installs normalizeRecordId as the default resource id normalizer", async () => {
|
|
88
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
89
|
+
client: {
|
|
90
|
+
config: {
|
|
91
|
+
client: "sqlite3"
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
async raw() {
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
version: "3.35.5"
|
|
98
|
+
}
|
|
99
|
+
];
|
|
100
|
+
},
|
|
101
|
+
transaction() {}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const api = await createJsonRestApiHost({
|
|
105
|
+
knex: fakeKnex
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.equal(api.vars.normalizeId, normalizeRecordId);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("shared query/document helpers build json-rest-api request shapes", () => {
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
buildJsonRestQueryParams("contacts", {
|
|
114
|
+
q: "Merc",
|
|
115
|
+
cursor: "cursor_2",
|
|
116
|
+
limit: 10,
|
|
117
|
+
include: "workspace,user",
|
|
118
|
+
sort: ["-createdAt", "name"],
|
|
119
|
+
fields: "name,dob"
|
|
120
|
+
}),
|
|
121
|
+
{
|
|
122
|
+
filters: {
|
|
123
|
+
q: "Merc"
|
|
124
|
+
},
|
|
125
|
+
include: ["workspace", "user"],
|
|
126
|
+
sort: ["-createdAt", "name"],
|
|
127
|
+
page: {
|
|
128
|
+
after: "cursor_2",
|
|
129
|
+
size: "10"
|
|
130
|
+
},
|
|
131
|
+
fields: {
|
|
132
|
+
contacts: "name,dob"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
createJsonApiInputRecord("contacts", {
|
|
139
|
+
name: "Merc"
|
|
140
|
+
}, {
|
|
141
|
+
id: 7,
|
|
142
|
+
relationships: {
|
|
143
|
+
workspace: createJsonApiRelationship("workspaces", 9)
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
{
|
|
147
|
+
data: {
|
|
148
|
+
type: "contacts",
|
|
149
|
+
id: "7",
|
|
150
|
+
attributes: {
|
|
151
|
+
name: "Merc"
|
|
152
|
+
},
|
|
153
|
+
relationships: {
|
|
154
|
+
workspace: {
|
|
155
|
+
data: {
|
|
156
|
+
type: "workspaces",
|
|
157
|
+
id: "9"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
simplifyJsonApiDocument({
|
|
167
|
+
data: [
|
|
168
|
+
{
|
|
169
|
+
type: "workspace-memberships",
|
|
170
|
+
id: "11",
|
|
171
|
+
attributes: {
|
|
172
|
+
roleSid: "owner"
|
|
173
|
+
},
|
|
174
|
+
relationships: {
|
|
175
|
+
user: {
|
|
176
|
+
data: {
|
|
177
|
+
type: "user-profiles",
|
|
178
|
+
id: "9"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
included: [
|
|
185
|
+
{
|
|
186
|
+
type: "user-profiles",
|
|
187
|
+
id: "9",
|
|
188
|
+
attributes: {
|
|
189
|
+
displayName: "Chiara"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}),
|
|
194
|
+
[
|
|
195
|
+
{
|
|
196
|
+
id: "11",
|
|
197
|
+
roleSid: "owner",
|
|
198
|
+
user: {
|
|
199
|
+
id: "9",
|
|
200
|
+
displayName: "Chiara"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("createJsonRestResourceScopeOptions clones canonical resource metadata and resolves symbolic write serializers", () => {
|
|
208
|
+
const serializer = (value) => value;
|
|
209
|
+
const normalizeId = (value) => String(value || "").trim() || null;
|
|
210
|
+
const source = Object.freeze({
|
|
211
|
+
namespace: "contacts",
|
|
212
|
+
tableName: "contacts",
|
|
213
|
+
defaultSort: Object.freeze(["-createdAt"]),
|
|
214
|
+
schema: Object.freeze({
|
|
215
|
+
name: Object.freeze({
|
|
216
|
+
type: "string",
|
|
217
|
+
maxLength: 190,
|
|
218
|
+
required: true,
|
|
219
|
+
search: true,
|
|
220
|
+
operations: Object.freeze({
|
|
221
|
+
output: Object.freeze({
|
|
222
|
+
required: true
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
}),
|
|
226
|
+
createdAt: Object.freeze({
|
|
227
|
+
type: "dateTime",
|
|
228
|
+
storage: Object.freeze({
|
|
229
|
+
column: "created_at",
|
|
230
|
+
writeSerializer: "datetime-utc"
|
|
231
|
+
}),
|
|
232
|
+
operations: Object.freeze({
|
|
233
|
+
output: Object.freeze({
|
|
234
|
+
required: true
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
}),
|
|
239
|
+
operations: Object.freeze({
|
|
240
|
+
view: Object.freeze({
|
|
241
|
+
method: "GET"
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = createJsonRestResourceScopeOptions(source, {
|
|
247
|
+
normalizeId,
|
|
248
|
+
writeSerializers: {
|
|
249
|
+
"datetime-utc": serializer
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
assert.notEqual(result, source);
|
|
254
|
+
assert.notEqual(result.schema, source.schema);
|
|
255
|
+
assert.notEqual(result.schema.createdAt, source.schema.createdAt);
|
|
256
|
+
assert.equal(result.schema.createdAt.storage.column, "created_at");
|
|
257
|
+
assert.equal(result.schema.createdAt.storage.serialize, serializer);
|
|
258
|
+
assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
|
|
259
|
+
assert.equal(result.normalizeId, normalizeId);
|
|
260
|
+
assert.equal(result.schema.name.maxLength, 190);
|
|
261
|
+
assert.equal(result.schema.name.operations.output.required, true);
|
|
262
|
+
assert.equal(result.operations.view.method, "GET");
|
|
263
|
+
|
|
264
|
+
result.schema.createdAt.indexed = true;
|
|
265
|
+
assert.equal(source.schema.createdAt.indexed, undefined);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
|
|
269
|
+
await assert.doesNotReject(async () => {
|
|
270
|
+
const result = await returnNullWhenJsonRestResourceMissing(async () => {
|
|
271
|
+
return "ok";
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
assert.equal(result, "ok");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const missing = Object.freeze({
|
|
278
|
+
code: "REST_API_RESOURCE",
|
|
279
|
+
subtype: "not_found"
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
assert.equal(isJsonRestResourceMissingError(missing), true);
|
|
283
|
+
assert.equal(await returnNullWhenJsonRestResourceMissing(async () => {
|
|
284
|
+
throw missing;
|
|
285
|
+
}), null);
|
|
286
|
+
|
|
287
|
+
const otherError = new Error("boom");
|
|
288
|
+
await assert.rejects(
|
|
289
|
+
async () => returnNullWhenJsonRestResourceMissing(async () => {
|
|
290
|
+
throw otherError;
|
|
291
|
+
}),
|
|
292
|
+
(error) => error === otherError
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("scope resolvers understand explicit scopeValues and JSKIT visibilityContext", () => {
|
|
297
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
298
|
+
scopeValues: {
|
|
299
|
+
workspaceId: "workspace-explicit"
|
|
300
|
+
},
|
|
301
|
+
visibilityContext: {
|
|
302
|
+
scopeOwnerId: "workspace-visibility"
|
|
303
|
+
}
|
|
304
|
+
}), "workspace-explicit");
|
|
305
|
+
|
|
306
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
307
|
+
visibilityContext: {
|
|
308
|
+
scopeOwnerId: 42
|
|
309
|
+
}
|
|
310
|
+
}), "42");
|
|
311
|
+
|
|
312
|
+
assert.equal(resolveUserScopeValue({
|
|
313
|
+
scopeValues: {
|
|
314
|
+
userId: "user-explicit"
|
|
315
|
+
},
|
|
316
|
+
visibilityContext: {
|
|
317
|
+
userId: "user-visibility"
|
|
318
|
+
}
|
|
319
|
+
}), "user-explicit");
|
|
320
|
+
|
|
321
|
+
assert.equal(resolveUserScopeValue({
|
|
322
|
+
visibilityContext: {
|
|
323
|
+
userId: 7
|
|
324
|
+
}
|
|
325
|
+
}), "7");
|
|
326
|
+
|
|
327
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
328
|
+
visibilityContext: {
|
|
329
|
+
scopeOwnerId: " "
|
|
330
|
+
}
|
|
331
|
+
}), null);
|
|
332
|
+
assert.equal(resolveUserScopeValue(null), null);
|
|
333
|
+
});
|