@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.
@@ -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.78",
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.78",
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.134"
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 { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
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 fields = normalizeJsonRestText(source.fields);
369
- if (normalizedResourceType && fields) {
370
- queryParams.fields = {
371
- [normalizedResourceType]: fields
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: "name,dob"
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
- contacts: "name,dob"
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: {