@jskit-ai/json-rest-api-core 0.1.78 → 0.1.80
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/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/json-rest-api-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.80",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Shared internal json-rest-api host runtime with autofilter, query-projection, and row-policy support.",
|
|
7
7
|
dependsOn: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/json-rest-api-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"hooked-api": "1.x.x",
|
|
13
13
|
"json-rest-api": "^1.0.26",
|
|
14
|
-
"@jskit-ai/kernel": "0.1.
|
|
14
|
+
"@jskit-ai/kernel": "0.1.136"
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -6,8 +6,16 @@ import {
|
|
|
6
6
|
RestApiPlugin,
|
|
7
7
|
RowPolicyPlugin
|
|
8
8
|
} from "json-rest-api";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
normalizeRecordId,
|
|
11
|
+
normalizeUniqueTextList
|
|
12
|
+
} from "@jskit-ai/kernel/shared/support/normalize";
|
|
10
13
|
import { resolveCrudResourceScopeName } from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
14
|
+
import {
|
|
15
|
+
normalizeJsonApiFieldList,
|
|
16
|
+
normalizeJsonApiFieldsets
|
|
17
|
+
} from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
|
|
18
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
11
19
|
|
|
12
20
|
const INTERNAL_JSON_REST_API = "internal.json-rest-api";
|
|
13
21
|
|
|
@@ -321,6 +329,51 @@ function applyJsonRestQueryFields(scopeOptions = {}, extraQueryFields = {}) {
|
|
|
321
329
|
}
|
|
322
330
|
}
|
|
323
331
|
|
|
332
|
+
function resolveJsonRestDefaultExcludedFields(resource = {}) {
|
|
333
|
+
const defaultExclude = resource?.contract?.response?.defaultExclude;
|
|
334
|
+
if (defaultExclude == null) {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
if (!Array.isArray(defaultExclude)) {
|
|
338
|
+
throw new TypeError("json-rest-api resource contract.response.defaultExclude must be an array.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return normalizeUniqueTextList(defaultExclude);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function applyJsonRestDefaultExclusions(scopeOptions = {}, resource = {}) {
|
|
345
|
+
const excludedFields = resolveJsonRestDefaultExcludedFields(resource);
|
|
346
|
+
if (excludedFields.length < 1) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const schema = normalizeJsonRestObject(scopeOptions.schema);
|
|
351
|
+
const queryFields = normalizeJsonRestObject(scopeOptions.queryFields);
|
|
352
|
+
const idProperty = normalizeJsonRestText(scopeOptions.idProperty, {
|
|
353
|
+
fallback: "id"
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
for (const field of excludedFields) {
|
|
357
|
+
if (field === "id" || field === idProperty) {
|
|
358
|
+
throw new TypeError(
|
|
359
|
+
`json-rest-api resource contract.response.defaultExclude cannot exclude identifier field "${field}".`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const definitions = Object.hasOwn(schema, field) ? schema : queryFields;
|
|
364
|
+
if (!Object.hasOwn(definitions, field)) {
|
|
365
|
+
throw new TypeError(
|
|
366
|
+
`json-rest-api resource contract.response.defaultExclude references unknown field "${field}".`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
definitions[field] = {
|
|
371
|
+
...normalizeJsonRestObject(definitions[field]),
|
|
372
|
+
normallyHidden: true
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
324
377
|
function buildJsonRestQueryParams(resourceType = "", query = {}, { include = undefined } = {}) {
|
|
325
378
|
const normalizedResourceType = normalizeJsonRestText(resourceType);
|
|
326
379
|
const source = normalizeJsonRestObject(query);
|
|
@@ -365,11 +418,27 @@ function buildJsonRestQueryParams(resourceType = "", query = {}, { include = und
|
|
|
365
418
|
};
|
|
366
419
|
}
|
|
367
420
|
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
421
|
+
const fieldsets = normalizeJsonApiFieldsets(source.fields, {
|
|
422
|
+
primaryType: normalizedResourceType
|
|
423
|
+
});
|
|
424
|
+
if (Object.keys(fieldsets).length > 0) {
|
|
425
|
+
const jsonRestFieldsets = new Map();
|
|
426
|
+
for (const [type, fields] of Object.entries(fieldsets)) {
|
|
427
|
+
const scopeName = resolveCrudResourceScopeName(type);
|
|
428
|
+
if (!scopeName) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
jsonRestFieldsets.set(scopeName, [
|
|
432
|
+
...(jsonRestFieldsets.get(scopeName) || []),
|
|
433
|
+
...fields
|
|
434
|
+
]);
|
|
435
|
+
}
|
|
436
|
+
queryParams.fields = Object.fromEntries(
|
|
437
|
+
[...jsonRestFieldsets.entries()].map(([scopeName, fields]) => [
|
|
438
|
+
scopeName,
|
|
439
|
+
normalizeJsonApiFieldList(fields).join(",")
|
|
440
|
+
])
|
|
441
|
+
);
|
|
373
442
|
}
|
|
374
443
|
|
|
375
444
|
return queryParams;
|
|
@@ -485,6 +554,7 @@ function createJsonRestResourceScopeOptions(
|
|
|
485
554
|
};
|
|
486
555
|
}
|
|
487
556
|
applyJsonRestQueryFields(scopeOptions, queryFields);
|
|
557
|
+
applyJsonRestDefaultExclusions(scopeOptions, resource);
|
|
488
558
|
const collectionRelationships = resolveJsonRestCollectionRelationships(scopeOptions);
|
|
489
559
|
if (Object.keys(collectionRelationships).length > 0) {
|
|
490
560
|
if (
|
|
@@ -576,6 +646,30 @@ async function returnNullWhenJsonRestResourceMissing(run) {
|
|
|
576
646
|
}
|
|
577
647
|
}
|
|
578
648
|
|
|
649
|
+
function isJsonRestSparseFieldError(error = null) {
|
|
650
|
+
return /^Unknown sparse field '.+' requested for '.+'$/u.test(
|
|
651
|
+
normalizeJsonRestText(error?.message)
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function returnBadRequestWhenJsonRestFieldsetInvalid(run) {
|
|
656
|
+
if (typeof run !== "function") {
|
|
657
|
+
throw new TypeError("returnBadRequestWhenJsonRestFieldsetInvalid requires run function.");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
return await run();
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (!isJsonRestSparseFieldError(error)) {
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
throw new AppError(400, error.message, {
|
|
668
|
+
code: "JSON_API_FIELDSET_INVALID"
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
579
673
|
async function createJsonRestApiHost({ knex }) {
|
|
580
674
|
if (typeof knex !== "function") {
|
|
581
675
|
throw new TypeError("createJsonRestApiHost requires knex.");
|
|
@@ -640,6 +734,7 @@ export {
|
|
|
640
734
|
extractJsonRestCollectionRows,
|
|
641
735
|
isJsonRestResourceMissingError,
|
|
642
736
|
returnNullWhenJsonRestResourceMissing,
|
|
737
|
+
returnBadRequestWhenJsonRestFieldsetInvalid,
|
|
643
738
|
resolveWorkspaceScopeValue,
|
|
644
739
|
resolveUserScopeValue,
|
|
645
740
|
createJsonRestApiHost,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isJsonRestResourceMissingError,
|
|
17
17
|
registerJsonRestApiHost,
|
|
18
18
|
returnNullWhenJsonRestResourceMissing,
|
|
19
|
+
returnBadRequestWhenJsonRestFieldsetInvalid,
|
|
19
20
|
resolveWorkspaceScopeValue,
|
|
20
21
|
resolveUserScopeValue
|
|
21
22
|
} from "../src/server/jsonRestApiHost.js";
|
|
@@ -47,6 +48,7 @@ test("server entrypoint exports shared host helpers", () => {
|
|
|
47
48
|
assert.equal(typeof isJsonRestResourceMissingError, "function");
|
|
48
49
|
assert.equal(typeof registerJsonRestApiHost, "function");
|
|
49
50
|
assert.equal(typeof returnNullWhenJsonRestResourceMissing, "function");
|
|
51
|
+
assert.equal(typeof returnBadRequestWhenJsonRestFieldsetInvalid, "function");
|
|
50
52
|
assert.equal(typeof resolveWorkspaceScopeValue, "function");
|
|
51
53
|
assert.equal(typeof resolveUserScopeValue, "function");
|
|
52
54
|
assert.equal(typeof JsonRestApiCoreServiceProvider, "function");
|
|
@@ -206,7 +208,11 @@ test("shared query/document helpers build json-rest-api request shapes", () => {
|
|
|
206
208
|
limit: 10,
|
|
207
209
|
include: "workspace,user",
|
|
208
210
|
sort: ["-createdAt", "name"],
|
|
209
|
-
fields:
|
|
211
|
+
fields: {
|
|
212
|
+
contacts: ["name", "dob"],
|
|
213
|
+
workspaces: ["slug", "id"],
|
|
214
|
+
"contact-notes": ["body", "id"]
|
|
215
|
+
}
|
|
210
216
|
}),
|
|
211
217
|
{
|
|
212
218
|
filters: {
|
|
@@ -219,7 +225,9 @@ test("shared query/document helpers build json-rest-api request shapes", () => {
|
|
|
219
225
|
size: "10"
|
|
220
226
|
},
|
|
221
227
|
fields: {
|
|
222
|
-
|
|
228
|
+
contactNotes: "body,id",
|
|
229
|
+
contacts: "dob,name",
|
|
230
|
+
workspaces: "id,slug"
|
|
223
231
|
}
|
|
224
232
|
}
|
|
225
233
|
);
|
|
@@ -480,6 +488,84 @@ test("createJsonRestResourceScopeOptions maps server query projections into json
|
|
|
480
488
|
assert.equal(resource.schema.remainingProcessableWeight.storage.virtual, true);
|
|
481
489
|
});
|
|
482
490
|
|
|
491
|
+
test("createJsonRestResourceScopeOptions applies resource-owned default response exclusions", () => {
|
|
492
|
+
const source = Object.freeze({
|
|
493
|
+
namespace: "contacts",
|
|
494
|
+
tableName: "contacts",
|
|
495
|
+
contract: Object.freeze({
|
|
496
|
+
response: Object.freeze({
|
|
497
|
+
defaultExclude: Object.freeze([
|
|
498
|
+
"privateNotes",
|
|
499
|
+
"searchRank"
|
|
500
|
+
])
|
|
501
|
+
})
|
|
502
|
+
}),
|
|
503
|
+
schema: Object.freeze({
|
|
504
|
+
id: Object.freeze({
|
|
505
|
+
type: "id",
|
|
506
|
+
primary: true
|
|
507
|
+
}),
|
|
508
|
+
name: Object.freeze({
|
|
509
|
+
type: "string"
|
|
510
|
+
}),
|
|
511
|
+
privateNotes: Object.freeze({
|
|
512
|
+
type: "string"
|
|
513
|
+
}),
|
|
514
|
+
searchRank: Object.freeze({
|
|
515
|
+
type: "number",
|
|
516
|
+
storage: Object.freeze({
|
|
517
|
+
virtual: true,
|
|
518
|
+
queryProjection: Object.freeze({
|
|
519
|
+
select() {}
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const result = createJsonRestResourceScopeOptions(source);
|
|
527
|
+
|
|
528
|
+
assert.equal(result.schema.name.normallyHidden, undefined);
|
|
529
|
+
assert.equal(result.schema.privateNotes.normallyHidden, true);
|
|
530
|
+
assert.equal(result.queryFields.searchRank.normallyHidden, true);
|
|
531
|
+
assert.equal(source.schema.privateNotes.normallyHidden, undefined);
|
|
532
|
+
assert.equal(source.schema.searchRank.normallyHidden, undefined);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("createJsonRestResourceScopeOptions rejects invalid default response exclusions", () => {
|
|
536
|
+
const createResource = (defaultExclude) => ({
|
|
537
|
+
namespace: "contacts",
|
|
538
|
+
tableName: "contacts",
|
|
539
|
+
contract: {
|
|
540
|
+
response: {
|
|
541
|
+
defaultExclude
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
schema: {
|
|
545
|
+
id: {
|
|
546
|
+
type: "id",
|
|
547
|
+
primary: true
|
|
548
|
+
},
|
|
549
|
+
name: {
|
|
550
|
+
type: "string"
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
assert.throws(
|
|
556
|
+
() => createJsonRestResourceScopeOptions(createResource("name")),
|
|
557
|
+
/contract\.response\.defaultExclude must be an array/
|
|
558
|
+
);
|
|
559
|
+
assert.throws(
|
|
560
|
+
() => createJsonRestResourceScopeOptions(createResource(["unknown"])),
|
|
561
|
+
/defaultExclude references unknown field "unknown"/
|
|
562
|
+
);
|
|
563
|
+
assert.throws(
|
|
564
|
+
() => createJsonRestResourceScopeOptions(createResource(["id"])),
|
|
565
|
+
/defaultExclude cannot exclude identifier field "id"/
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
483
569
|
test("createJsonRestResourceScopeOptions rejects query field names for column-backed schema fields", () => {
|
|
484
570
|
assert.throws(
|
|
485
571
|
() => createJsonRestResourceScopeOptions({
|
|
@@ -563,6 +649,30 @@ test("returnNullWhenJsonRestResourceMissing only swallows missing-resource error
|
|
|
563
649
|
);
|
|
564
650
|
});
|
|
565
651
|
|
|
652
|
+
test("invalid sparse fields become a stable 400 without swallowing unrelated failures", async () => {
|
|
653
|
+
const sparseFieldError = new Error("Unknown sparse field 'passwordHash' requested for 'contacts'");
|
|
654
|
+
|
|
655
|
+
await assert.rejects(
|
|
656
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(async () => {
|
|
657
|
+
throw sparseFieldError;
|
|
658
|
+
}),
|
|
659
|
+
(error) => {
|
|
660
|
+
assert.equal(error.status, 400);
|
|
661
|
+
assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
|
|
662
|
+
assert.equal(error.message, sparseFieldError.message);
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
const unrelatedError = new Error("database unavailable");
|
|
668
|
+
await assert.rejects(
|
|
669
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(async () => {
|
|
670
|
+
throw unrelatedError;
|
|
671
|
+
}),
|
|
672
|
+
(error) => error === unrelatedError
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
566
676
|
test("scope resolvers understand explicit scopeValues and JSKIT visibilityContext", () => {
|
|
567
677
|
assert.equal(resolveWorkspaceScopeValue({
|
|
568
678
|
scopeValues: {
|