@jskit-ai/crud-core 0.1.179 → 0.1.181
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.json +6 -6
- package/patterns/json-api-resource-package/example/packages/books/package.json +2 -2
- package/src/server/jsonApiModule/actions.js +177 -35
- package/src/server/jsonApiResourceContract.js +119 -0
- package/src/server/listQueryValidators.js +76 -11
- package/src/server/routeContracts.js +1 -69
- package/test/assistantActionConformance.test.js +180 -44
- package/test/listQueryValidators.test.js +59 -0
- package/test/routeContracts.test.js +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.181",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"./server/routeContracts": "./src/server/routeContracts.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
27
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
28
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
26
|
+
"@jskit-ai/database-runtime": "0.1.170",
|
|
27
|
+
"@jskit-ai/json-rest-api-core": "0.1.114",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.112",
|
|
29
29
|
"json-rest-schema": "^1.0.17"
|
|
30
30
|
},
|
|
31
31
|
"description": "Shared server-side CRUD service, repository, route, and query helpers.",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
49
|
-
"@jskit-ai/kernel": "0.1.
|
|
48
|
+
"@jskit-ai/http-runtime": "0.1.168",
|
|
49
|
+
"@jskit-ai/kernel": "0.1.170"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"./shared": "./src/shared/index.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/crud-core": "0.1.
|
|
12
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
11
|
+
"@jskit-ai/crud-core": "0.1.181",
|
|
12
|
+
"@jskit-ai/resource-crud-core": "0.1.112"
|
|
13
13
|
},
|
|
14
14
|
"jskit": {
|
|
15
15
|
"kind": "runtime",
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createSchema,
|
|
4
4
|
recordIdParamsValidator
|
|
5
5
|
} from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import { normalizeJsonApiFieldsets } from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
|
|
6
7
|
import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
|
|
7
8
|
import {
|
|
8
9
|
decodeJsonApiResourceResponse,
|
|
@@ -10,8 +11,13 @@ import {
|
|
|
10
11
|
unwrapJsonApiResult
|
|
11
12
|
} from "@jskit-ai/http-runtime/shared";
|
|
12
13
|
import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
|
|
13
|
-
import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
|
|
14
14
|
import {
|
|
15
|
+
resolveJsonApiFieldsetContract,
|
|
16
|
+
resolveJsonApiRelationshipEntries,
|
|
17
|
+
resolveSchemaFieldDefinitions
|
|
18
|
+
} from "../jsonApiResourceContract.js";
|
|
19
|
+
import {
|
|
20
|
+
createJsonApiFieldsetsQueryValidator,
|
|
15
21
|
createStandardCrudListQueryValidators,
|
|
16
22
|
createStandardCrudViewQueryValidators
|
|
17
23
|
} from "../listQueryValidators.js";
|
|
@@ -49,11 +55,6 @@ function resolveAssistantResultValue(result) {
|
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
function resolveSchemaFieldDefinitions(definition = null) {
|
|
53
|
-
const definitions = definition?.schema?.getFieldDefinitions?.();
|
|
54
|
-
return isRecord(definitions) ? definitions : {};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
58
|
function createProjectionRecordSchema(recordSchema, {
|
|
58
59
|
lookupContainerKey = "",
|
|
59
60
|
relationshipEntries = []
|
|
@@ -166,6 +167,102 @@ function createCrudAssistantTransport(resource, operation, relationshipEntries,
|
|
|
166
167
|
});
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
function projectCrudAssistantRecordFields(record, selectedFields = null, {
|
|
171
|
+
preserveKeys = []
|
|
172
|
+
} = {}) {
|
|
173
|
+
if (!isRecord(record) || !Array.isArray(selectedFields)) {
|
|
174
|
+
return record;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowedKeys = new Set(["id", ...preserveKeys, ...selectedFields]);
|
|
178
|
+
return Object.fromEntries(
|
|
179
|
+
Object.entries(record).filter(([key]) => allowedKeys.has(key))
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function projectCrudAssistantLookupValue(value, selectedFields = null) {
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
return value.map((entry) => projectCrudAssistantRecordFields(entry, selectedFields));
|
|
186
|
+
}
|
|
187
|
+
return projectCrudAssistantRecordFields(value, selectedFields);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function projectCrudAssistantReadRecord(record, {
|
|
191
|
+
lookupContainerKey = "",
|
|
192
|
+
primaryFields = null,
|
|
193
|
+
relatedFieldsByLookupKey = new Map()
|
|
194
|
+
} = {}) {
|
|
195
|
+
if (!isRecord(record)) {
|
|
196
|
+
return record;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const primaryProjection = projectCrudAssistantRecordFields(record, primaryFields, {
|
|
200
|
+
preserveKeys: lookupContainerKey && isRecord(record[lookupContainerKey])
|
|
201
|
+
? [lookupContainerKey]
|
|
202
|
+
: []
|
|
203
|
+
});
|
|
204
|
+
const projectedRecord = primaryProjection === record ? { ...record } : primaryProjection;
|
|
205
|
+
if (!lookupContainerKey || !isRecord(projectedRecord[lookupContainerKey])) {
|
|
206
|
+
return projectedRecord;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
projectedRecord[lookupContainerKey] = Object.fromEntries(
|
|
210
|
+
Object.entries(projectedRecord[lookupContainerKey]).map(([lookupKey, value]) => {
|
|
211
|
+
return [
|
|
212
|
+
lookupKey,
|
|
213
|
+
projectCrudAssistantLookupValue(value, relatedFieldsByLookupKey.get(lookupKey))
|
|
214
|
+
];
|
|
215
|
+
})
|
|
216
|
+
);
|
|
217
|
+
return projectedRecord;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function createCrudAssistantResultProjection(input = {}, resource = {}, relationshipEntries = []) {
|
|
221
|
+
const primaryType = String(resource?.namespace || "").trim();
|
|
222
|
+
const fieldsets = normalizeJsonApiFieldsets(input?.fields, {
|
|
223
|
+
primaryType
|
|
224
|
+
});
|
|
225
|
+
if (Object.keys(fieldsets).length < 1) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const relatedFieldsByLookupKey = new Map();
|
|
230
|
+
for (const entry of relationshipEntries) {
|
|
231
|
+
const selectedFields = fieldsets[entry.relationshipType];
|
|
232
|
+
if (!Array.isArray(selectedFields)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
relatedFieldsByLookupKey.set(entry.relationshipName, selectedFields);
|
|
236
|
+
relatedFieldsByLookupKey.set(entry.attributeKey, selectedFields);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
primaryFields: fieldsets[primaryType],
|
|
241
|
+
relatedFieldsByLookupKey
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function projectCrudAssistantReadResult(result, projection = null, {
|
|
246
|
+
lookupContainerKey = ""
|
|
247
|
+
} = {}) {
|
|
248
|
+
if (!projection) {
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const projectionOptions = {
|
|
253
|
+
...projection,
|
|
254
|
+
lookupContainerKey
|
|
255
|
+
};
|
|
256
|
+
if (isRecord(result) && Array.isArray(result.items)) {
|
|
257
|
+
return {
|
|
258
|
+
...result,
|
|
259
|
+
items: result.items.map((entry) => projectCrudAssistantReadRecord(entry, projectionOptions))
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return projectCrudAssistantReadRecord(result, projectionOptions);
|
|
264
|
+
}
|
|
265
|
+
|
|
169
266
|
function transformCrudAssistantResult(operation, result, {
|
|
170
267
|
input = {},
|
|
171
268
|
resource,
|
|
@@ -184,39 +281,84 @@ function transformCrudAssistantResult(operation, result, {
|
|
|
184
281
|
}
|
|
185
282
|
|
|
186
283
|
const resolved = resolveAssistantResultValue(result);
|
|
284
|
+
const projection = operation === "list" || operation === "view"
|
|
285
|
+
? createCrudAssistantResultProjection(input, resource, relationshipEntries)
|
|
286
|
+
: null;
|
|
287
|
+
const projectReadResult = (value) => projectCrudAssistantReadResult(value, projection, {
|
|
288
|
+
lookupContainerKey
|
|
289
|
+
});
|
|
187
290
|
if (operation === "list") {
|
|
188
291
|
if (resolved.document.kind === "collection") {
|
|
189
292
|
const decoded = decodeJsonApiResourceResponse(
|
|
190
293
|
resolved.value,
|
|
191
294
|
createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
|
|
192
295
|
);
|
|
193
|
-
return {
|
|
296
|
+
return projectReadResult({
|
|
194
297
|
items: decoded.items,
|
|
195
298
|
nextCursor: normalizeOptionalCursor(decoded.nextCursor)
|
|
196
|
-
};
|
|
299
|
+
});
|
|
197
300
|
}
|
|
198
301
|
if (Array.isArray(resolved.value)) {
|
|
199
|
-
return {
|
|
302
|
+
return projectReadResult({
|
|
200
303
|
items: resolved.value,
|
|
201
304
|
nextCursor: null
|
|
202
|
-
};
|
|
305
|
+
});
|
|
203
306
|
}
|
|
204
307
|
if (isRecord(resolved.value) && Array.isArray(resolved.value.items)) {
|
|
205
|
-
return {
|
|
308
|
+
return projectReadResult({
|
|
206
309
|
items: resolved.value.items,
|
|
207
310
|
nextCursor: normalizeOptionalCursor(resolved.value.nextCursor)
|
|
208
|
-
};
|
|
311
|
+
});
|
|
209
312
|
}
|
|
210
313
|
return resolved.value;
|
|
211
314
|
}
|
|
212
315
|
|
|
213
316
|
if (resolved.document.kind === "resource") {
|
|
214
|
-
return decodeJsonApiResourceResponse(
|
|
317
|
+
return projectReadResult(decodeJsonApiResourceResponse(
|
|
215
318
|
resolved.value,
|
|
216
319
|
createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
|
|
217
|
-
);
|
|
320
|
+
));
|
|
218
321
|
}
|
|
219
|
-
return resolved.value;
|
|
322
|
+
return projectReadResult(resolved.value);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function createCrudAssistantReadDescription({
|
|
326
|
+
fieldsetContract,
|
|
327
|
+
lookupContainerKey = "",
|
|
328
|
+
namespace = "",
|
|
329
|
+
operation = "list"
|
|
330
|
+
} = {}) {
|
|
331
|
+
const relationshipEntries = fieldsetContract.relationshipEntries;
|
|
332
|
+
const firstRelationship = relationshipEntries[0] || null;
|
|
333
|
+
const includeExample = relationshipEntries.length > 0
|
|
334
|
+
? relationshipEntries.slice(0, 2).map((entry) => entry.relationshipName).join(",")
|
|
335
|
+
: "pet,service";
|
|
336
|
+
const fieldsExample = firstRelationship
|
|
337
|
+
? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
|
|
338
|
+
`\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
|
|
339
|
+
: ` fields can be {\"${namespace}\":[\"id\"]}.`;
|
|
340
|
+
const aliasGuidance = fieldsetContract.aliasMappings
|
|
341
|
+
.map((entry) => `use \"${entry.resourceType}\" instead of \"${entry.alias}\"`)
|
|
342
|
+
.join("; ");
|
|
343
|
+
const fieldsetKeyGuidance = fieldsetContract.resourceTypes.length > 0
|
|
344
|
+
? ` fields keys must be JSON:API resource types: ${fieldsetContract.resourceTypes.map((entry) => `\"${entry}\"`).join(", ")}.` +
|
|
345
|
+
(aliasGuidance ? ` Relationship aliases are invalid fieldset keys; ${aliasGuidance}.` : "")
|
|
346
|
+
: "";
|
|
347
|
+
const primaryFieldGuidance = fieldsetContract.primaryFields.length > 0
|
|
348
|
+
? ` Valid \"${namespace}\" fields: ${fieldsetContract.primaryFields.map((entry) => `\"${entry}\"`).join(", ")}.`
|
|
349
|
+
: "";
|
|
350
|
+
const relationshipGuidance = relationshipEntries.length > 0
|
|
351
|
+
? ` Include relationships: ${relationshipEntries.map((entry) => {
|
|
352
|
+
const lookupPath = lookupContainerKey
|
|
353
|
+
? ` -> ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.${entry.relationshipName}`
|
|
354
|
+
: "";
|
|
355
|
+
return `\"${entry.relationshipName}\" -> resource type \"${entry.relationshipType}\"${lookupPath}`;
|
|
356
|
+
}).join("; ")}.`
|
|
357
|
+
: "";
|
|
358
|
+
|
|
359
|
+
const subject = operation === "list" ? `List ${namespace} records.` : `View a ${namespace} record.`;
|
|
360
|
+
return `${subject} include must be a comma-separated string such as \"${includeExample}\";` +
|
|
361
|
+
`${fieldsExample}${fieldsetKeyGuidance}${primaryFieldGuidance}${relationshipGuidance}`;
|
|
220
362
|
}
|
|
221
363
|
|
|
222
364
|
function createCrudAssistantExtension(resource, namespace, operation) {
|
|
@@ -236,24 +378,18 @@ function createCrudAssistantExtension(resource, namespace, operation) {
|
|
|
236
378
|
relationshipEntries,
|
|
237
379
|
lookupContainerKey
|
|
238
380
|
);
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
? `View a ${namespace} record. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
|
|
252
|
-
: operation === "create"
|
|
253
|
-
? `Create a ${namespace} record.`
|
|
254
|
-
: operation === "update"
|
|
255
|
-
? `Update a ${namespace} record.`
|
|
256
|
-
: `Delete a ${namespace} record.`;
|
|
381
|
+
const actionLabel = operation === "list" || operation === "view"
|
|
382
|
+
? createCrudAssistantReadDescription({
|
|
383
|
+
fieldsetContract: resolveJsonApiFieldsetContract(resource),
|
|
384
|
+
lookupContainerKey,
|
|
385
|
+
namespace,
|
|
386
|
+
operation
|
|
387
|
+
})
|
|
388
|
+
: operation === "create"
|
|
389
|
+
? `Create a ${namespace} record.`
|
|
390
|
+
: operation === "update"
|
|
391
|
+
? `Update a ${namespace} record.`
|
|
392
|
+
: `Delete a ${namespace} record.`;
|
|
257
393
|
|
|
258
394
|
return Object.freeze({
|
|
259
395
|
description: actionLabel,
|
|
@@ -278,9 +414,15 @@ function createActionInput(
|
|
|
278
414
|
) {
|
|
279
415
|
const definitions = scopeInputValidator ? [scopeInputValidator] : [];
|
|
280
416
|
if (operation === "list") {
|
|
281
|
-
definitions.push(...createStandardCrudListQueryValidators({
|
|
417
|
+
definitions.push(...createStandardCrudListQueryValidators({
|
|
418
|
+
resource,
|
|
419
|
+
listFilterQueryValidator,
|
|
420
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
421
|
+
}));
|
|
282
422
|
} else if (operation === "view") {
|
|
283
|
-
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators(
|
|
423
|
+
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
|
|
424
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
425
|
+
}));
|
|
284
426
|
} else if (operation === "create") {
|
|
285
427
|
definitions.push(operationInputs.create || resource.operations.create.body);
|
|
286
428
|
} else if (operation === "update") {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
|
|
3
|
+
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function resolveSchemaFieldDefinitions(definition = null) {
|
|
9
|
+
const schema = definition?.schema;
|
|
10
|
+
if (!schema || typeof schema.getFieldDefinitions !== "function") {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const definitions = schema.getFieldDefinitions();
|
|
15
|
+
return isRecord(definitions) ? definitions : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveJsonApiRelationshipEntries(definition = null) {
|
|
19
|
+
const entries = [];
|
|
20
|
+
|
|
21
|
+
for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(definition))) {
|
|
22
|
+
const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
|
|
23
|
+
const relationshipType = String(normalizedFieldDefinition.belongsTo || "").trim();
|
|
24
|
+
if (relationshipType) {
|
|
25
|
+
const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
|
|
26
|
+
if (!relationshipName) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
entries.push(Object.freeze({
|
|
31
|
+
attributeKey: fieldKey,
|
|
32
|
+
relationshipName,
|
|
33
|
+
relationshipType,
|
|
34
|
+
labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
|
|
35
|
+
required: normalizedFieldDefinition.required === true,
|
|
36
|
+
nullable: normalizedFieldDefinition.nullable === true
|
|
37
|
+
}));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const relation = isRecord(normalizedFieldDefinition.relation)
|
|
42
|
+
? normalizedFieldDefinition.relation
|
|
43
|
+
: {};
|
|
44
|
+
if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const collectionRelationshipType = resolveCrudResourceScopeName(
|
|
49
|
+
relation.target || relation.targetResource || relation.namespace || relation.apiPath
|
|
50
|
+
);
|
|
51
|
+
if (!collectionRelationshipType) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const collectionRelationshipName = String(
|
|
56
|
+
relation.as || normalizedFieldDefinition.as || fieldKey || ""
|
|
57
|
+
).trim();
|
|
58
|
+
if (!collectionRelationshipName) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
entries.push(Object.freeze({
|
|
63
|
+
attributeKey: fieldKey,
|
|
64
|
+
relationshipName: collectionRelationshipName,
|
|
65
|
+
relationshipType: collectionRelationshipType,
|
|
66
|
+
labelKey: String(relation.labelKey || "").trim(),
|
|
67
|
+
many: true,
|
|
68
|
+
required: normalizedFieldDefinition.required === true,
|
|
69
|
+
nullable: normalizedFieldDefinition.nullable === true
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.freeze(entries);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveJsonApiFieldsetContract(resource = {}) {
|
|
77
|
+
const primaryType = normalizeText(resource?.namespace);
|
|
78
|
+
const relationshipEntries = resolveJsonApiRelationshipEntries(resource?.operations?.view?.output);
|
|
79
|
+
const lookupContainerKey = normalizeText(resource?.contract?.lookup?.containerKey);
|
|
80
|
+
const primaryFields = Object.freeze(
|
|
81
|
+
Object.keys(resolveSchemaFieldDefinitions(resource?.operations?.view?.output))
|
|
82
|
+
.map((entry) => normalizeText(entry))
|
|
83
|
+
.filter((entry) => entry && entry !== lookupContainerKey)
|
|
84
|
+
);
|
|
85
|
+
const aliasMappings = Object.freeze(
|
|
86
|
+
relationshipEntries
|
|
87
|
+
.filter((entry) => entry.relationshipName !== entry.relationshipType)
|
|
88
|
+
.map((entry) => Object.freeze({
|
|
89
|
+
alias: entry.relationshipName,
|
|
90
|
+
resourceType: entry.relationshipType
|
|
91
|
+
}))
|
|
92
|
+
);
|
|
93
|
+
const resourceTypes = [];
|
|
94
|
+
const seenTypes = new Set();
|
|
95
|
+
for (const resourceType of [
|
|
96
|
+
primaryType,
|
|
97
|
+
...relationshipEntries.map((entry) => normalizeText(entry.relationshipType))
|
|
98
|
+
]) {
|
|
99
|
+
if (!resourceType || seenTypes.has(resourceType)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seenTypes.add(resourceType);
|
|
103
|
+
resourceTypes.push(resourceType);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
aliasMappings,
|
|
108
|
+
primaryType,
|
|
109
|
+
primaryFields,
|
|
110
|
+
relationshipEntries,
|
|
111
|
+
resourceTypes: Object.freeze(resourceTypes)
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
resolveJsonApiFieldsetContract,
|
|
117
|
+
resolveJsonApiRelationshipEntries,
|
|
118
|
+
resolveSchemaFieldDefinitions
|
|
119
|
+
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from "@jskit-ai/kernel/shared/validators";
|
|
5
5
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
6
6
|
import { resolveCrudParentFilterKeys as resolveSharedCrudParentFilterKeys } from "@jskit-ai/resource-crud-core/shared/crudLookup";
|
|
7
|
+
import { resolveJsonApiFieldsetContract } from "./jsonApiResourceContract.js";
|
|
7
8
|
|
|
8
9
|
const listSearchQueryValidator = Object.freeze({
|
|
9
10
|
schema: createSchema({
|
|
@@ -28,6 +29,28 @@ const lookupIncludeQueryValidator = Object.freeze({
|
|
|
28
29
|
mode: "patch"
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
function createJsonApiFieldsetValueDefinition({ allowedFields = [] } = {}) {
|
|
33
|
+
const normalizedAllowedFields = [...new Set(
|
|
34
|
+
(Array.isArray(allowedFields) ? allowedFields : [])
|
|
35
|
+
.map((entry) => normalizeText(entry))
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
)];
|
|
38
|
+
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
type: "array",
|
|
41
|
+
required: false,
|
|
42
|
+
items: {
|
|
43
|
+
type: "string",
|
|
44
|
+
minLength: 1,
|
|
45
|
+
...(normalizedAllowedFields.length > 0
|
|
46
|
+
? { enum: Object.freeze(normalizedAllowedFields) }
|
|
47
|
+
: {})
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const jsonApiFieldsetValueDefinition = createJsonApiFieldsetValueDefinition();
|
|
53
|
+
|
|
31
54
|
const jsonApiFieldsetsQueryValidator = Object.freeze({
|
|
32
55
|
schema: createSchema({
|
|
33
56
|
fields: {
|
|
@@ -36,18 +59,57 @@ const jsonApiFieldsetsQueryValidator = Object.freeze({
|
|
|
36
59
|
messages: {
|
|
37
60
|
default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
|
|
38
61
|
},
|
|
39
|
-
values:
|
|
40
|
-
type: "array",
|
|
41
|
-
items: {
|
|
42
|
-
type: "string",
|
|
43
|
-
minLength: 1
|
|
44
|
-
}
|
|
45
|
-
}
|
|
62
|
+
values: jsonApiFieldsetValueDefinition
|
|
46
63
|
}
|
|
47
64
|
}),
|
|
48
65
|
mode: "patch"
|
|
49
66
|
});
|
|
50
67
|
|
|
68
|
+
function createJsonApiFieldsetsQueryValidator({ resource = {} } = {}) {
|
|
69
|
+
const contract = resolveJsonApiFieldsetContract(resource);
|
|
70
|
+
if (!contract.primaryType || contract.resourceTypes.length < 1) {
|
|
71
|
+
return jsonApiFieldsetsQueryValidator;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const firstRelationship = contract.relationshipEntries[0] || null;
|
|
75
|
+
const example = firstRelationship
|
|
76
|
+
? `{"${contract.primaryType}":["${firstRelationship.attributeKey}"],` +
|
|
77
|
+
`"${firstRelationship.relationshipType}":["${firstRelationship.labelKey || "id"}"]}`
|
|
78
|
+
: `{"${contract.primaryType}":["id"]}`;
|
|
79
|
+
const allowedTypes = contract.resourceTypes.map((entry) => `"${entry}"`).join(", ");
|
|
80
|
+
const aliasGuidance = contract.aliasMappings
|
|
81
|
+
.map((entry) => `use "${entry.resourceType}" instead of "${entry.alias}"`)
|
|
82
|
+
.join("; ");
|
|
83
|
+
const additionalPropertiesMessage =
|
|
84
|
+
`fields keys must be JSON:API resource types. Allowed keys: ${allowedTypes}.` +
|
|
85
|
+
(aliasGuidance ? ` Relationship aliases are invalid keys; ${aliasGuidance}.` : "");
|
|
86
|
+
const fieldsetSchema = createSchema(
|
|
87
|
+
Object.fromEntries(
|
|
88
|
+
contract.resourceTypes.map((resourceType) => [
|
|
89
|
+
resourceType,
|
|
90
|
+
createJsonApiFieldsetValueDefinition({
|
|
91
|
+
allowedFields: resourceType === contract.primaryType ? contract.primaryFields : []
|
|
92
|
+
})
|
|
93
|
+
])
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
schema: createSchema({
|
|
99
|
+
fields: {
|
|
100
|
+
type: "object",
|
|
101
|
+
required: false,
|
|
102
|
+
schema: fieldsetSchema,
|
|
103
|
+
messages: {
|
|
104
|
+
default: `fields expects an object keyed by JSON:API resource type, such as ${example}.`,
|
|
105
|
+
additionalProperties: additionalPropertiesMessage
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
mode: "patch"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
51
113
|
function resolveCrudListUsesOrderedCursor(list = {}) {
|
|
52
114
|
const entries = Array.isArray(list?.orderBy)
|
|
53
115
|
? list.orderBy
|
|
@@ -115,7 +177,8 @@ function createStandardCrudListQueryValidators({
|
|
|
115
177
|
resource = {},
|
|
116
178
|
listFilterQueryValidator = null,
|
|
117
179
|
searchQueryValidator = listSearchQueryValidator,
|
|
118
|
-
includeQueryValidator = lookupIncludeQueryValidator
|
|
180
|
+
includeQueryValidator = lookupIncludeQueryValidator,
|
|
181
|
+
fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
|
|
119
182
|
} = {}) {
|
|
120
183
|
const resolvedListFilterQueryValidator =
|
|
121
184
|
listFilterQueryValidator
|
|
@@ -132,21 +195,23 @@ function createStandardCrudListQueryValidators({
|
|
|
132
195
|
? [resolvedListFilterQueryValidator]
|
|
133
196
|
: []),
|
|
134
197
|
includeQueryValidator,
|
|
135
|
-
|
|
198
|
+
fieldsetsQueryValidator
|
|
136
199
|
];
|
|
137
200
|
}
|
|
138
201
|
|
|
139
202
|
function createStandardCrudViewQueryValidators({
|
|
140
|
-
includeQueryValidator = lookupIncludeQueryValidator
|
|
203
|
+
includeQueryValidator = lookupIncludeQueryValidator,
|
|
204
|
+
fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
|
|
141
205
|
} = {}) {
|
|
142
206
|
return [
|
|
143
207
|
includeQueryValidator,
|
|
144
|
-
|
|
208
|
+
fieldsetsQueryValidator
|
|
145
209
|
];
|
|
146
210
|
}
|
|
147
211
|
|
|
148
212
|
export {
|
|
149
213
|
createCrudCursorPaginationQueryValidator,
|
|
214
|
+
createJsonApiFieldsetsQueryValidator,
|
|
150
215
|
listSearchQueryValidator,
|
|
151
216
|
lookupIncludeQueryValidator,
|
|
152
217
|
jsonApiFieldsetsQueryValidator,
|
|
@@ -6,86 +6,18 @@ import {
|
|
|
6
6
|
composeSchemaDefinitions,
|
|
7
7
|
recordIdParamsValidator
|
|
8
8
|
} from "@jskit-ai/kernel/shared/validators";
|
|
9
|
-
import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
|
|
10
9
|
import {
|
|
11
10
|
createStandardCrudListQueryValidators,
|
|
12
11
|
createStandardCrudViewQueryValidators,
|
|
13
12
|
listSearchQueryValidator as defaultListSearchQueryValidator,
|
|
14
13
|
lookupIncludeQueryValidator as defaultLookupIncludeQueryValidator
|
|
15
14
|
} from "./listQueryValidators.js";
|
|
15
|
+
import { resolveJsonApiRelationshipEntries } from "./jsonApiResourceContract.js";
|
|
16
16
|
|
|
17
17
|
function isRecord(value) {
|
|
18
18
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function resolveSchemaFieldDefinitions(definition = null) {
|
|
22
|
-
const schema = definition?.schema;
|
|
23
|
-
if (!schema || typeof schema.getFieldDefinitions !== "function") {
|
|
24
|
-
return {};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const definitions = schema.getFieldDefinitions();
|
|
28
|
-
return isRecord(definitions) ? definitions : {};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function resolveJsonApiRelationshipEntries(definition = null) {
|
|
32
|
-
const entries = [];
|
|
33
|
-
|
|
34
|
-
for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(definition))) {
|
|
35
|
-
const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
|
|
36
|
-
const relationshipType = String(normalizedFieldDefinition.belongsTo || "").trim();
|
|
37
|
-
if (relationshipType) {
|
|
38
|
-
const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
|
|
39
|
-
if (!relationshipName) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
entries.push(Object.freeze({
|
|
44
|
-
attributeKey: fieldKey,
|
|
45
|
-
relationshipName,
|
|
46
|
-
relationshipType,
|
|
47
|
-
labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
|
|
48
|
-
required: normalizedFieldDefinition.required === true,
|
|
49
|
-
nullable: normalizedFieldDefinition.nullable === true
|
|
50
|
-
}));
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const relation = isRecord(normalizedFieldDefinition.relation)
|
|
55
|
-
? normalizedFieldDefinition.relation
|
|
56
|
-
: {};
|
|
57
|
-
if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const collectionRelationshipType = resolveCrudResourceScopeName(
|
|
62
|
-
relation.target || relation.targetResource || relation.namespace || relation.apiPath
|
|
63
|
-
);
|
|
64
|
-
if (!collectionRelationshipType) {
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const collectionRelationshipName = String(
|
|
69
|
-
relation.as || normalizedFieldDefinition.as || fieldKey || ""
|
|
70
|
-
).trim();
|
|
71
|
-
if (!collectionRelationshipName) {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
entries.push(Object.freeze({
|
|
76
|
-
attributeKey: fieldKey,
|
|
77
|
-
relationshipName: collectionRelationshipName,
|
|
78
|
-
relationshipType: collectionRelationshipType,
|
|
79
|
-
labelKey: String(relation.labelKey || "").trim(),
|
|
80
|
-
many: true,
|
|
81
|
-
required: normalizedFieldDefinition.required === true,
|
|
82
|
-
nullable: normalizedFieldDefinition.nullable === true
|
|
83
|
-
}));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return Object.freeze(entries);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
21
|
function readOwnValue(source = {}, key = "") {
|
|
90
22
|
if (isRecord(source) && Object.hasOwn(source, key)) {
|
|
91
23
|
return {
|
|
@@ -11,14 +11,46 @@ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudReso
|
|
|
11
11
|
import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
|
|
12
12
|
|
|
13
13
|
const BOOKINGS = Object.freeze([
|
|
14
|
-
Object.freeze({
|
|
15
|
-
|
|
14
|
+
Object.freeze({
|
|
15
|
+
id: "41",
|
|
16
|
+
serviceId: "11",
|
|
17
|
+
productId: "21",
|
|
18
|
+
contactId: "31",
|
|
19
|
+
petId: "7",
|
|
20
|
+
summary: "Wash and trim"
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: "42",
|
|
24
|
+
serviceId: "12",
|
|
25
|
+
productId: "22",
|
|
26
|
+
contactId: "32",
|
|
27
|
+
petId: "8",
|
|
28
|
+
summary: "Nail trim"
|
|
29
|
+
})
|
|
16
30
|
]);
|
|
17
31
|
const PETS = Object.freeze({
|
|
18
|
-
"7": Object.freeze({ id: "7", name: "Fido", species: "dog" }),
|
|
19
|
-
"8": Object.freeze({ id: "8", name: "Mabel", species: "cat" })
|
|
32
|
+
"7": Object.freeze({ id: "7", contactId: "31", breedId: "91", name: "Fido", species: "dog" }),
|
|
33
|
+
"8": Object.freeze({ id: "8", contactId: "32", breedId: "92", name: "Mabel", species: "cat" })
|
|
20
34
|
});
|
|
21
35
|
|
|
36
|
+
function lookupField(namespace, relationshipName) {
|
|
37
|
+
return {
|
|
38
|
+
type: "id",
|
|
39
|
+
required: true,
|
|
40
|
+
belongsTo: namespace,
|
|
41
|
+
as: relationshipName,
|
|
42
|
+
relation: {
|
|
43
|
+
kind: "lookup",
|
|
44
|
+
namespace,
|
|
45
|
+
valueKey: "id",
|
|
46
|
+
labelKey: "name"
|
|
47
|
+
},
|
|
48
|
+
operations: {
|
|
49
|
+
output: { required: true }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
function createBookingsResource() {
|
|
23
55
|
return defineCrudResource({
|
|
24
56
|
namespace: "bookings",
|
|
@@ -30,21 +62,10 @@ function createBookingsResource() {
|
|
|
30
62
|
}
|
|
31
63
|
},
|
|
32
64
|
schema: {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
as: "pet",
|
|
38
|
-
relation: {
|
|
39
|
-
kind: "lookup",
|
|
40
|
-
namespace: "pets",
|
|
41
|
-
valueKey: "id",
|
|
42
|
-
labelKey: "name"
|
|
43
|
-
},
|
|
44
|
-
operations: {
|
|
45
|
-
output: { required: true }
|
|
46
|
-
}
|
|
47
|
-
},
|
|
65
|
+
serviceId: lookupField("services", "service"),
|
|
66
|
+
productId: lookupField("products", "product"),
|
|
67
|
+
contactId: lookupField("contacts", "contact"),
|
|
68
|
+
petId: lookupField("pets", "pet"),
|
|
48
69
|
summary: {
|
|
49
70
|
type: "string",
|
|
50
71
|
required: true,
|
|
@@ -77,20 +98,24 @@ function createBookingDocument(query = {}) {
|
|
|
77
98
|
.includes("pet");
|
|
78
99
|
const rows = BOOKINGS.slice(0, limit);
|
|
79
100
|
const data = rows.map((booking) => {
|
|
80
|
-
const includePetLinkage = !selectedBookings || selectedBookings.includes("petId");
|
|
81
101
|
return {
|
|
82
102
|
type: "bookings",
|
|
83
103
|
id: booking.id,
|
|
84
104
|
attributes: selectFields({ summary: booking.summary }, selectedBookings),
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
relationships: {
|
|
106
|
+
service: {
|
|
107
|
+
data: { type: "services", id: booking.serviceId }
|
|
108
|
+
},
|
|
109
|
+
product: {
|
|
110
|
+
data: { type: "products", id: booking.productId }
|
|
111
|
+
},
|
|
112
|
+
contact: {
|
|
113
|
+
data: { type: "contacts", id: booking.contactId }
|
|
114
|
+
},
|
|
115
|
+
pet: {
|
|
116
|
+
data: { type: "pets", id: booking.petId }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
94
119
|
};
|
|
95
120
|
});
|
|
96
121
|
const included = includesPet
|
|
@@ -100,7 +125,15 @@ function createBookingDocument(query = {}) {
|
|
|
100
125
|
attributes: selectFields({
|
|
101
126
|
name: PETS[booking.petId].name,
|
|
102
127
|
species: PETS[booking.petId].species
|
|
103
|
-
}, selectedPets)
|
|
128
|
+
}, selectedPets),
|
|
129
|
+
relationships: {
|
|
130
|
+
contact: {
|
|
131
|
+
data: { type: "contacts", id: PETS[booking.petId].contactId }
|
|
132
|
+
},
|
|
133
|
+
breed: {
|
|
134
|
+
data: { type: "breeds", id: PETS[booking.petId].breedId }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
104
137
|
}))
|
|
105
138
|
: [];
|
|
106
139
|
|
|
@@ -123,8 +156,12 @@ function createFixture() {
|
|
|
123
156
|
calls.push({ query, context });
|
|
124
157
|
return returnJsonApiDocument(createBookingDocument(query));
|
|
125
158
|
},
|
|
126
|
-
async getDocumentById() {
|
|
127
|
-
|
|
159
|
+
async getDocumentById(_recordId, query) {
|
|
160
|
+
const document = createBookingDocument(query);
|
|
161
|
+
return returnJsonApiDocument({
|
|
162
|
+
data: document.data[0],
|
|
163
|
+
...(document.included ? { included: document.included } : {})
|
|
164
|
+
});
|
|
128
165
|
}
|
|
129
166
|
};
|
|
130
167
|
const definitions = createCrudJsonApiActions({
|
|
@@ -206,11 +243,32 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
206
243
|
assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
|
|
207
244
|
assert.equal(contract.ok, true);
|
|
208
245
|
assert.equal(contract.result.inputSchema.properties.include.type, "string");
|
|
209
|
-
|
|
246
|
+
const fieldsetInputSchema = resolveLocalSchemaReference(
|
|
247
|
+
contract.result.inputSchema,
|
|
248
|
+
contract.result.inputSchema.properties.fields
|
|
249
|
+
);
|
|
250
|
+
assert.deepEqual(
|
|
251
|
+
Object.keys(fieldsetInputSchema.properties),
|
|
252
|
+
["bookings", "services", "products", "contacts", "pets"]
|
|
253
|
+
);
|
|
254
|
+
assert.equal(fieldsetInputSchema.additionalProperties, false);
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
fieldsetInputSchema.properties.bookings.items.enum,
|
|
257
|
+
["id", "serviceId", "productId", "contactId", "petId", "summary"]
|
|
258
|
+
);
|
|
259
|
+
assert.equal(Object.hasOwn(fieldsetInputSchema.properties.pets.items, "enum"), false);
|
|
210
260
|
assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
|
|
211
261
|
assert.match(contract.result.description, /include must be a comma-separated string/u);
|
|
212
|
-
assert.match(contract.result.description, /\{"bookings":\["
|
|
213
|
-
assert.match(
|
|
262
|
+
assert.match(contract.result.description, /\{"bookings":\["serviceId"\],"services":\["name"\]\}/u);
|
|
263
|
+
assert.match(
|
|
264
|
+
contract.result.description,
|
|
265
|
+
/fields keys must be JSON:API resource types: "bookings", "services", "products", "contacts", "pets"/u
|
|
266
|
+
);
|
|
267
|
+
assert.match(contract.result.description, /use "pets" instead of "pet"/u);
|
|
268
|
+
assert.match(
|
|
269
|
+
contract.result.description,
|
|
270
|
+
/"pet" -> resource type "pets" -> items\[\]\.lookups\.pet/u
|
|
271
|
+
);
|
|
214
272
|
const primaryOutputSchema = resolveLocalSchemaReference(
|
|
215
273
|
contract.result.outputSchema,
|
|
216
274
|
contract.result.outputSchema.properties.items.items
|
|
@@ -229,21 +287,35 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
229
287
|
const response = await executeList(fixture, toolSet, { limit: 5 });
|
|
230
288
|
assert.equal(response.ok, true);
|
|
231
289
|
assert.deepEqual(response.result.result.items, [
|
|
232
|
-
{
|
|
233
|
-
|
|
290
|
+
{
|
|
291
|
+
id: "41",
|
|
292
|
+
serviceId: 11,
|
|
293
|
+
productId: 21,
|
|
294
|
+
contactId: 31,
|
|
295
|
+
petId: 7,
|
|
296
|
+
summary: "Wash and trim"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "42",
|
|
300
|
+
serviceId: 12,
|
|
301
|
+
productId: 22,
|
|
302
|
+
contactId: 32,
|
|
303
|
+
petId: 8,
|
|
304
|
+
summary: "Nail trim"
|
|
305
|
+
}
|
|
234
306
|
]);
|
|
235
307
|
assert.equal(response.result.result.nextCursor, null);
|
|
236
308
|
});
|
|
237
309
|
|
|
238
310
|
await t.test("sparse primary fields only", async () => {
|
|
239
311
|
const response = await executeList(fixture, toolSet, {
|
|
240
|
-
fields: { bookings: ["
|
|
312
|
+
fields: { bookings: ["serviceId"] },
|
|
241
313
|
limit: 5
|
|
242
314
|
});
|
|
243
315
|
assert.equal(response.ok, true);
|
|
244
316
|
assert.deepEqual(response.result.result.items, [
|
|
245
|
-
{ id: "41",
|
|
246
|
-
{ id: "42",
|
|
317
|
+
{ id: "41", serviceId: 11 },
|
|
318
|
+
{ id: "42", serviceId: 12 }
|
|
247
319
|
]);
|
|
248
320
|
});
|
|
249
321
|
|
|
@@ -252,6 +324,8 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
252
324
|
assert.equal(response.ok, true);
|
|
253
325
|
assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
|
|
254
326
|
assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
|
|
327
|
+
assert.equal(response.result.result.items[0].lookups.pet.contactId, "31");
|
|
328
|
+
assert.equal(response.result.result.items[0].lookups.pet.breedId, "91");
|
|
255
329
|
assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
|
|
256
330
|
});
|
|
257
331
|
|
|
@@ -259,7 +333,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
259
333
|
const response = await executeList(fixture, toolSet, {
|
|
260
334
|
workspaceSlug: "model-controlled-workspace",
|
|
261
335
|
include: "pet",
|
|
262
|
-
fields: { bookings: ["
|
|
336
|
+
fields: { bookings: ["id", "serviceId"], pets: ["name"] },
|
|
263
337
|
limit: 5
|
|
264
338
|
});
|
|
265
339
|
assert.equal(response.ok, true);
|
|
@@ -267,7 +341,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
267
341
|
assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
|
|
268
342
|
{
|
|
269
343
|
id: "41",
|
|
270
|
-
|
|
344
|
+
serviceId: 11,
|
|
271
345
|
lookups: {
|
|
272
346
|
petId: { id: "7", name: "Fido" },
|
|
273
347
|
pet: { id: "7", name: "Fido" }
|
|
@@ -275,7 +349,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
275
349
|
},
|
|
276
350
|
{
|
|
277
351
|
id: "42",
|
|
278
|
-
|
|
352
|
+
serviceId: 12,
|
|
279
353
|
lookups: {
|
|
280
354
|
petId: { id: "8", name: "Mabel" },
|
|
281
355
|
pet: { id: "8", name: "Mabel" }
|
|
@@ -283,7 +357,12 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
283
357
|
}
|
|
284
358
|
]);
|
|
285
359
|
assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
|
|
360
|
+
assert.equal(Object.hasOwn(response.result.result.items[0], "productId"), false);
|
|
361
|
+
assert.equal(Object.hasOwn(response.result.result.items[0], "contactId"), false);
|
|
362
|
+
assert.equal(Object.hasOwn(response.result.result.items[0], "petId"), false);
|
|
286
363
|
assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
|
|
364
|
+
assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "contactId"), false);
|
|
365
|
+
assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "breedId"), false);
|
|
287
366
|
assert.deepEqual(
|
|
288
367
|
validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
|
|
289
368
|
phase: "output"
|
|
@@ -291,9 +370,47 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
291
370
|
response.result.result
|
|
292
371
|
);
|
|
293
372
|
assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
|
|
373
|
+
assert.deepEqual(fixture.calls.at(-1).query.fields, {
|
|
374
|
+
bookings: ["id", "serviceId"],
|
|
375
|
+
pets: ["name"]
|
|
376
|
+
});
|
|
294
377
|
assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
|
|
295
378
|
});
|
|
296
379
|
|
|
380
|
+
await t.test("sparse view projection removes unselected relationship linkage", async () => {
|
|
381
|
+
const viewContract = await fixture.catalog.executeToolCall({
|
|
382
|
+
toolName: "assistant_action_contract",
|
|
383
|
+
argumentsText: JSON.stringify({ actionId: "crud.bookings.view", version: 1 }),
|
|
384
|
+
context: fixture.context,
|
|
385
|
+
toolSet
|
|
386
|
+
});
|
|
387
|
+
assert.equal(viewContract.ok, true);
|
|
388
|
+
|
|
389
|
+
const response = await fixture.catalog.executeToolCall({
|
|
390
|
+
toolName: "assistant_action_execute",
|
|
391
|
+
argumentsText: JSON.stringify({
|
|
392
|
+
actionId: "crud.bookings.view",
|
|
393
|
+
version: 1,
|
|
394
|
+
input: {
|
|
395
|
+
recordId: "41",
|
|
396
|
+
include: "pet",
|
|
397
|
+
fields: { bookings: ["serviceId"], pets: ["name"] }
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
400
|
+
context: fixture.context,
|
|
401
|
+
toolSet
|
|
402
|
+
});
|
|
403
|
+
assert.equal(response.ok, true);
|
|
404
|
+
assert.deepEqual(JSON.parse(JSON.stringify(response.result.result)), {
|
|
405
|
+
id: "41",
|
|
406
|
+
serviceId: 11,
|
|
407
|
+
lookups: {
|
|
408
|
+
petId: { id: "7", name: "Fido" },
|
|
409
|
+
pet: { id: "7", name: "Fido" }
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
297
414
|
await t.test("limit and cursor truncation remain bounded", async () => {
|
|
298
415
|
const response = await executeList(fixture, toolSet, { limit: 1 });
|
|
299
416
|
assert.equal(response.ok, true);
|
|
@@ -317,8 +434,27 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
317
434
|
assert.equal(response.error.status, 400);
|
|
318
435
|
assert.match(
|
|
319
436
|
response.error.message,
|
|
320
|
-
/fields expects an object such as \{"bookings":\["
|
|
437
|
+
/fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["serviceId"\],"services":\["name"\]\}/u
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await t.test("relationship aliases are rejected as sparse-fieldset resource keys", async () => {
|
|
442
|
+
const callCount = fixture.calls.length;
|
|
443
|
+
const response = await executeList(fixture, toolSet, {
|
|
444
|
+
include: "pet",
|
|
445
|
+
fields: { pet: ["name"] },
|
|
446
|
+
limit: 3
|
|
447
|
+
});
|
|
448
|
+
assert.equal(response.ok, false);
|
|
449
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
450
|
+
assert.equal(response.error.status, 400);
|
|
451
|
+
assert.match(response.error.message, /fields\.pet: fields keys must be JSON:API resource types/u);
|
|
452
|
+
assert.match(
|
|
453
|
+
response.error.message,
|
|
454
|
+
/Allowed keys: "bookings", "services", "products", "contacts", "pets"/u
|
|
321
455
|
);
|
|
456
|
+
assert.match(response.error.message, /use "pets" instead of "pet"/u);
|
|
457
|
+
assert.equal(fixture.calls.length, callCount);
|
|
322
458
|
});
|
|
323
459
|
|
|
324
460
|
await t.test("permission denial removes the generated action from native discovery", async () => {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
listSearchQueryValidator,
|
|
11
11
|
lookupIncludeQueryValidator,
|
|
12
12
|
createCrudCursorPaginationQueryValidator,
|
|
13
|
+
createJsonApiFieldsetsQueryValidator,
|
|
13
14
|
createCrudParentFilterQueryValidator,
|
|
14
15
|
createStandardCrudListQueryValidators,
|
|
15
16
|
createStandardCrudViewQueryValidators,
|
|
@@ -26,11 +27,13 @@ function composeSchemaDefinition(...definitions) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function createCrudResource({
|
|
30
|
+
namespace = "",
|
|
29
31
|
viewFields = {},
|
|
30
32
|
createFields = {},
|
|
31
33
|
patchFields = {}
|
|
32
34
|
} = {}) {
|
|
33
35
|
return {
|
|
36
|
+
...(namespace ? { namespace } : {}),
|
|
34
37
|
operations: {
|
|
35
38
|
view: {
|
|
36
39
|
output: {
|
|
@@ -90,6 +93,62 @@ test("lookupIncludeQueryValidator keeps include optional when merged with pagina
|
|
|
90
93
|
assert.deepEqual(compiled.schema.querystring.required || [], []);
|
|
91
94
|
});
|
|
92
95
|
|
|
96
|
+
test("resource-aware sparse fieldsets enumerate JSON:API types and reject relationship aliases", () => {
|
|
97
|
+
const resource = createCrudResource({
|
|
98
|
+
namespace: "bookings",
|
|
99
|
+
viewFields: {
|
|
100
|
+
petId: {
|
|
101
|
+
type: "integer",
|
|
102
|
+
belongsTo: "pets",
|
|
103
|
+
as: "pet",
|
|
104
|
+
relation: {
|
|
105
|
+
labelKey: "name"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
serviceId: {
|
|
109
|
+
type: "integer",
|
|
110
|
+
belongsTo: "services",
|
|
111
|
+
as: "service",
|
|
112
|
+
relation: {
|
|
113
|
+
labelKey: "name"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const validator = createJsonApiFieldsetsQueryValidator({ resource });
|
|
119
|
+
const transportSchema = validator.schema.toJsonSchema({ mode: "patch" });
|
|
120
|
+
const fieldsetReference = transportSchema.properties.fields.allOf[0].$ref;
|
|
121
|
+
const fieldsetSchema = transportSchema.definitions[fieldsetReference.slice("#/definitions/".length)];
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(Object.keys(fieldsetSchema.properties), ["bookings", "pets", "services"]);
|
|
124
|
+
assert.equal(fieldsetSchema.additionalProperties, false);
|
|
125
|
+
assert.deepEqual(fieldsetSchema.properties.bookings.items.enum, ["petId", "serviceId"]);
|
|
126
|
+
assert.equal(Object.hasOwn(fieldsetSchema.properties.pets.items, "enum"), false);
|
|
127
|
+
assert.deepEqual(validateSchemaPayload(validator, {
|
|
128
|
+
fields: {
|
|
129
|
+
bookings: ["petId"],
|
|
130
|
+
pets: ["name"]
|
|
131
|
+
}
|
|
132
|
+
}, { phase: "input" }), {
|
|
133
|
+
fields: {
|
|
134
|
+
bookings: ["petId"],
|
|
135
|
+
pets: ["name"]
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
assert.throws(
|
|
139
|
+
() => validateSchemaPayload(validator, {
|
|
140
|
+
fields: {
|
|
141
|
+
pet: ["name"]
|
|
142
|
+
}
|
|
143
|
+
}, { phase: "input" }),
|
|
144
|
+
(error) => {
|
|
145
|
+
assert.match(error.fieldErrors?.["fields.pet"] || "", /Allowed keys: "bookings", "pets", "services"/u);
|
|
146
|
+
assert.match(error.fieldErrors?.["fields.pet"] || "", /use "pets" instead of "pet"/u);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
93
152
|
test("createCrudCursorPaginationQueryValidator keeps numeric cursor validation for unordered lists", () => {
|
|
94
153
|
const validator = createCrudCursorPaginationQueryValidator({});
|
|
95
154
|
|
|
@@ -180,7 +180,8 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
|
|
|
180
180
|
limit: "25",
|
|
181
181
|
fields: {
|
|
182
182
|
contacts: ["id", "name"],
|
|
183
|
-
userProfiles: ["id", "name"]
|
|
183
|
+
userProfiles: ["id", "name"],
|
|
184
|
+
organizations: ["name"]
|
|
184
185
|
}
|
|
185
186
|
}, { phase: "input" });
|
|
186
187
|
|
|
@@ -192,7 +193,8 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
|
|
|
192
193
|
limit: 25,
|
|
193
194
|
fields: {
|
|
194
195
|
contacts: ["id", "name"],
|
|
195
|
-
userProfiles: ["id", "name"]
|
|
196
|
+
userProfiles: ["id", "name"],
|
|
197
|
+
organizations: ["name"]
|
|
196
198
|
}
|
|
197
199
|
});
|
|
198
200
|
|