@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
+ });