@jskit-ai/json-rest-api-core 0.1.37 → 0.1.38

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.37",
4
+ version: "0.1.38",
5
5
  kind: "runtime",
6
6
  description: "Shared internal json-rest-api host runtime for JSKIT server packages.",
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.37",
3
+ "version": "0.1.38",
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.x.x",
14
- "@jskit-ai/kernel": "0.1.92"
14
+ "@jskit-ai/kernel": "0.1.93"
15
15
  }
16
16
  }
@@ -1,5 +1,5 @@
1
1
  import { Api } from "hooked-api";
2
- import { AutoFilterPlugin, RestApiKnexPlugin, RestApiPlugin } from "json-rest-api";
2
+ import { AutoFilterPlugin, QueryProjectionsPlugin, RestApiKnexPlugin, RestApiPlugin } from "json-rest-api";
3
3
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { resolveCrudResourceScopeName } from "@jskit-ai/kernel/shared/support/crudLookup";
5
5
 
@@ -125,6 +125,45 @@ function normalizeJsonRestText(value, { fallback = "" } = {}) {
125
125
  return normalized || fallback;
126
126
  }
127
127
 
128
+ function normalizeJsonRestFilterValue(value) {
129
+ if (value == null) {
130
+ return undefined;
131
+ }
132
+
133
+ if (Array.isArray(value)) {
134
+ const entries = value
135
+ .map((entry) => normalizeJsonRestFilterValue(entry))
136
+ .filter((entry) => entry !== undefined);
137
+ return entries.length > 0 ? entries : undefined;
138
+ }
139
+
140
+ if (isPlainJsonRestObject(value)) {
141
+ const entries = Object.entries(value)
142
+ .map(([key, entry]) => [normalizeJsonRestText(key), normalizeJsonRestFilterValue(entry)])
143
+ .filter(([key, entry]) => key && entry !== undefined);
144
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
145
+ }
146
+
147
+ if (typeof value === "string") {
148
+ const normalized = normalizeJsonRestText(value);
149
+ return normalized || undefined;
150
+ }
151
+
152
+ if (typeof value === "number") {
153
+ return Number.isFinite(value) ? value : undefined;
154
+ }
155
+
156
+ if (typeof value === "bigint") {
157
+ return String(value);
158
+ }
159
+
160
+ if (typeof value === "boolean") {
161
+ return value;
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
128
167
  function normalizeJsonRestObject(value) {
129
168
  if (!value || typeof value !== "object" || Array.isArray(value)) {
130
169
  return {};
@@ -183,6 +222,99 @@ function resolveJsonRestCollectionRelationships(resource = {}) {
183
222
  return relationships;
184
223
  }
185
224
 
225
+ function normalizeJsonRestQueryField(fieldName = "", fieldDefinition = {}, projectionDefinition = null) {
226
+ const normalizedFieldName = normalizeJsonRestText(fieldName);
227
+ const sourceProjection = isPlainJsonRestObject(projectionDefinition)
228
+ ? projectionDefinition
229
+ : isPlainJsonRestObject(fieldDefinition?.storage?.queryProjection)
230
+ ? fieldDefinition.storage.queryProjection
231
+ : isPlainJsonRestObject(fieldDefinition?.queryProjection)
232
+ ? fieldDefinition.queryProjection
233
+ : null;
234
+ if (!normalizedFieldName || !sourceProjection) {
235
+ return null;
236
+ }
237
+
238
+ const sourceFieldDefinition = isPlainJsonRestObject(fieldDefinition) ? fieldDefinition : {};
239
+ const select = typeof sourceProjection.select === "function"
240
+ ? sourceProjection.select
241
+ : typeof sourceProjection.project === "function"
242
+ ? sourceProjection.project
243
+ : null;
244
+ const type = normalizeJsonRestText(sourceProjection.type || sourceFieldDefinition.type);
245
+ return {
246
+ ...sourceProjection,
247
+ ...(type ? { type } : {}),
248
+ ...(select ? { select } : {})
249
+ };
250
+ }
251
+
252
+ function isJsonRestVirtualField(fieldDefinition = null) {
253
+ return fieldDefinition?.virtual === true ||
254
+ fieldDefinition?.storage?.virtual === true ||
255
+ Boolean(fieldDefinition?.storage?.queryProjection || fieldDefinition?.queryProjection);
256
+ }
257
+
258
+ function applyJsonRestQueryFields(scopeOptions = {}, extraQueryFields = {}) {
259
+ const schema = normalizeJsonRestObject(scopeOptions.schema);
260
+ const queryFields = {
261
+ ...normalizeJsonRestObject(scopeOptions.queryFields)
262
+ };
263
+
264
+ for (const [fieldName, fieldDefinition] of Object.entries(schema)) {
265
+ const queryField = normalizeJsonRestQueryField(fieldName, fieldDefinition);
266
+ if (!queryField) {
267
+ continue;
268
+ }
269
+
270
+ queryFields[fieldName] = queryField;
271
+ delete schema[fieldName];
272
+ }
273
+
274
+ for (const key of Object.keys(queryFields)) {
275
+ const schemaFieldDefinition = schema[key];
276
+ if (!schemaFieldDefinition) {
277
+ continue;
278
+ }
279
+ if (!isJsonRestVirtualField(schemaFieldDefinition)) {
280
+ throw new Error(
281
+ `json-rest-api query field "${key}" conflicts with a column-backed schema field.`
282
+ );
283
+ }
284
+ delete schema[key];
285
+ }
286
+
287
+ for (const [fieldName, projectionDefinition] of Object.entries(normalizeJsonRestObject(extraQueryFields))) {
288
+ const key = normalizeJsonRestText(fieldName);
289
+ if (!key) {
290
+ continue;
291
+ }
292
+
293
+ const schemaFieldDefinition = schema[key];
294
+ if (schemaFieldDefinition && !isJsonRestVirtualField(schemaFieldDefinition)) {
295
+ throw new Error(
296
+ `json-rest-api query field "${key}" conflicts with a column-backed schema field.`
297
+ );
298
+ }
299
+
300
+ const queryField = normalizeJsonRestQueryField(key, schemaFieldDefinition, projectionDefinition);
301
+ if (!queryField) {
302
+ continue;
303
+ }
304
+
305
+ queryFields[key] = queryField;
306
+ if (schemaFieldDefinition) {
307
+ delete schema[key];
308
+ }
309
+ }
310
+
311
+ if (Object.keys(queryFields).length > 0) {
312
+ scopeOptions.queryFields = queryFields;
313
+ } else {
314
+ delete scopeOptions.queryFields;
315
+ }
316
+ }
317
+
186
318
  function buildJsonRestQueryParams(resourceType = "", query = {}, { include = undefined } = {}) {
187
319
  const normalizedResourceType = normalizeJsonRestText(resourceType);
188
320
  const source = normalizeJsonRestObject(query);
@@ -194,8 +326,8 @@ function buildJsonRestQueryParams(resourceType = "", query = {}, { include = und
194
326
  continue;
195
327
  }
196
328
 
197
- const normalizedValue = normalizeJsonRestText(rawValue);
198
- if (!normalizedValue) {
329
+ const normalizedValue = normalizeJsonRestFilterValue(rawValue);
330
+ if (normalizedValue === undefined) {
199
331
  continue;
200
332
  }
201
333
 
@@ -327,10 +459,25 @@ function createJsonApiRelationship(resourceType = "", id = null) {
327
459
  };
328
460
  }
329
461
 
330
- function createJsonRestResourceScopeOptions(resource = {}, { writeSerializers = {}, normalizeId = null } = {}) {
462
+ function createJsonRestResourceScopeOptions(
463
+ resource = {},
464
+ {
465
+ writeSerializers = {},
466
+ normalizeId = null,
467
+ searchSchema = null,
468
+ queryFields = null
469
+ } = {}
470
+ ) {
331
471
  const scopeOptions = cloneJsonRestResourceValue(resource, {
332
472
  writeSerializers: normalizeJsonRestObject(writeSerializers)
333
473
  });
474
+ if (isPlainJsonRestObject(searchSchema)) {
475
+ scopeOptions.searchSchema = {
476
+ ...normalizeJsonRestObject(scopeOptions.searchSchema),
477
+ ...searchSchema
478
+ };
479
+ }
480
+ applyJsonRestQueryFields(scopeOptions, queryFields);
334
481
  const collectionRelationships = resolveJsonRestCollectionRelationships(scopeOptions);
335
482
  if (Object.keys(collectionRelationships).length > 0) {
336
483
  if (
@@ -442,6 +589,7 @@ async function createJsonRestApiHost({ knex }) {
442
589
  normalizeId: normalizeRecordId
443
590
  });
444
591
 
592
+ await api.use(QueryProjectionsPlugin);
445
593
  await api.use(RestApiKnexPlugin, { knex });
446
594
  await api.use(AutoFilterPlugin, {
447
595
  resolvers: {
@@ -249,6 +249,38 @@ test("shared query/document helpers build json-rest-api request shapes", () => {
249
249
 
250
250
  });
251
251
 
252
+ test("buildJsonRestQueryParams preserves structured filter values", () => {
253
+ assert.deepEqual(
254
+ buildJsonRestQueryParams("receivals", {
255
+ status: ["pending", "open", ""],
256
+ supplierId: [7, " 8 "],
257
+ availability: true,
258
+ weight: {
259
+ min: 12.5,
260
+ max: " 18 "
261
+ },
262
+ emptyList: [],
263
+ emptyObject: {},
264
+ emptyText: " ",
265
+ cursor: "cursor_1"
266
+ }),
267
+ {
268
+ filters: {
269
+ status: ["pending", "open"],
270
+ supplierId: [7, "8"],
271
+ availability: true,
272
+ weight: {
273
+ min: 12.5,
274
+ max: "18"
275
+ }
276
+ },
277
+ page: {
278
+ after: "cursor_1"
279
+ }
280
+ }
281
+ );
282
+ });
283
+
252
284
  test("createJsonRestResourceScopeOptions clones canonical resource metadata and resolves symbolic write serializers", () => {
253
285
  const serializer = (value) => value;
254
286
  const normalizeId = (value) => String(value || "").trim() || null;
@@ -356,6 +388,109 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
356
388
  assert.equal(source.schema.createdAt.indexed, undefined);
357
389
  });
358
390
 
391
+ test("createJsonRestResourceScopeOptions maps server query projections into json-rest-api queryFields", () => {
392
+ const select = ({ knex }) => knex.raw("1");
393
+ const resource = {
394
+ namespace: "receivals",
395
+ schema: {
396
+ id: { type: "id", primary: true },
397
+ remainingProcessableWeight: {
398
+ type: "number",
399
+ storage: {
400
+ virtual: true,
401
+ queryProjection: {
402
+ sortable: true,
403
+ select
404
+ }
405
+ }
406
+ },
407
+ availableCapacityWeight: {
408
+ type: "number",
409
+ storage: {
410
+ virtual: true
411
+ }
412
+ }
413
+ }
414
+ };
415
+
416
+ const result = createJsonRestResourceScopeOptions(resource, {
417
+ queryFields: {
418
+ availableCapacityWeight: {
419
+ type: "number",
420
+ select
421
+ }
422
+ },
423
+ searchSchema: {
424
+ status: { type: "string", filterOperator: "=" }
425
+ }
426
+ });
427
+
428
+ assert.deepEqual(Object.keys(result.schema), ["id"]);
429
+ assert.equal(result.queryFields.remainingProcessableWeight.type, "number");
430
+ assert.equal(result.queryFields.remainingProcessableWeight.sortable, true);
431
+ assert.equal(result.queryFields.remainingProcessableWeight.select, select);
432
+ assert.equal(result.queryFields.availableCapacityWeight.select, select);
433
+ assert.deepEqual(result.searchSchema, {
434
+ status: { type: "string", filterOperator: "=" }
435
+ });
436
+ assert.equal(resource.schema.remainingProcessableWeight.storage.virtual, true);
437
+ });
438
+
439
+ test("createJsonRestResourceScopeOptions rejects query field names for column-backed schema fields", () => {
440
+ assert.throws(
441
+ () => createJsonRestResourceScopeOptions({
442
+ namespace: "receivals",
443
+ schema: {
444
+ id: { type: "id", primary: true },
445
+ status: { type: "string" }
446
+ }
447
+ }, {
448
+ queryFields: {
449
+ status: {
450
+ type: "string",
451
+ select() {}
452
+ }
453
+ }
454
+ }),
455
+ /query field "status" conflicts with a column-backed schema field/
456
+ );
457
+ });
458
+
459
+ test("createJsonRestApiHost installs json-rest-api query projections", async () => {
460
+ const fakeKnex = Object.assign(() => {}, {
461
+ client: {
462
+ config: {
463
+ client: "sqlite3"
464
+ }
465
+ },
466
+ raw(sql) {
467
+ if (String(sql || "").includes("sqlite_version")) {
468
+ return [{ version: "3.35.5" }];
469
+ }
470
+ return { sql };
471
+ },
472
+ async transaction() {}
473
+ });
474
+ const api = await createJsonRestApiHost({ knex: fakeKnex });
475
+
476
+ await api.addResource("projectionContacts", {
477
+ tableName: "projection_contacts",
478
+ schema: {
479
+ id: { type: "id", primary: true }
480
+ },
481
+ queryFields: {
482
+ displayName: {
483
+ type: "string",
484
+ select({ knex }) {
485
+ return knex.raw("'Display'");
486
+ }
487
+ }
488
+ }
489
+ });
490
+
491
+ assert.equal(typeof api.resources.projectionContacts.vars.queryFields.displayName.select, "function");
492
+ });
493
+
359
494
  test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
360
495
  await assert.doesNotReject(async () => {
361
496
  const result = await returnNullWhenJsonRestResourceMissing(async () => {