@jskit-ai/crud-core 0.1.178 → 0.1.180
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 +8 -6
- package/patterns/json-api-resource-package/example/packages/books/package.json +2 -2
- package/src/server/jsonApiModule/actions.js +216 -24
- package/src/server/jsonApiResourceContract.js +119 -0
- package/src/server/listQueryValidators.js +83 -12
- package/src/server/routeContracts.js +2 -68
- package/test/assistantActionConformance.test.js +372 -0
- package/test/defineCrudJsonApiFeature.test.js +9 -2
- 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.180",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -23,11 +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/
|
|
28
|
-
"@jskit-ai/
|
|
29
|
-
"@jskit-ai/kernel": "0.1.167",
|
|
30
|
-
"@jskit-ai/resource-crud-core": "0.1.109",
|
|
26
|
+
"@jskit-ai/database-runtime": "0.1.169",
|
|
27
|
+
"@jskit-ai/json-rest-api-core": "0.1.113",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.111",
|
|
31
29
|
"json-rest-schema": "^1.0.17"
|
|
32
30
|
},
|
|
33
31
|
"description": "Shared server-side CRUD service, repository, route, and query helpers.",
|
|
@@ -45,5 +43,9 @@
|
|
|
45
43
|
"providers": []
|
|
46
44
|
}
|
|
47
45
|
}
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@jskit-ai/http-runtime": "0.1.167",
|
|
49
|
+
"@jskit-ai/kernel": "0.1.169"
|
|
48
50
|
}
|
|
49
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.180",
|
|
12
|
+
"@jskit-ai/resource-crud-core": "0.1.111"
|
|
13
13
|
},
|
|
14
14
|
"jskit": {
|
|
15
15
|
"kind": "runtime",
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
2
|
composeSchemaDefinitions,
|
|
3
|
+
createSchema,
|
|
3
4
|
recordIdParamsValidator
|
|
4
5
|
} from "@jskit-ai/kernel/shared/validators";
|
|
5
6
|
import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
|
|
6
7
|
import {
|
|
8
|
+
decodeJsonApiResourceResponse,
|
|
7
9
|
normalizeJsonApiDocument,
|
|
8
|
-
simplifyJsonApiDocument,
|
|
9
10
|
unwrapJsonApiResult
|
|
10
11
|
} from "@jskit-ai/http-runtime/shared";
|
|
11
12
|
import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
|
|
12
13
|
import {
|
|
14
|
+
resolveJsonApiFieldsetContract,
|
|
15
|
+
resolveJsonApiRelationshipEntries,
|
|
16
|
+
resolveSchemaFieldDefinitions
|
|
17
|
+
} from "../jsonApiResourceContract.js";
|
|
18
|
+
import {
|
|
19
|
+
createJsonApiFieldsetsQueryValidator,
|
|
13
20
|
createStandardCrudListQueryValidators,
|
|
14
21
|
createStandardCrudViewQueryValidators
|
|
15
22
|
} from "../listQueryValidators.js";
|
|
@@ -37,12 +44,6 @@ function normalizeOptionalCursor(value) {
|
|
|
37
44
|
return normalized || null;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
function resolveDocumentNextCursor(document = {}) {
|
|
41
|
-
return normalizeOptionalCursor(
|
|
42
|
-
document?.meta?.page?.nextCursor ?? document?.meta?.pagination?.cursor?.next
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
47
|
function resolveAssistantResultValue(result) {
|
|
47
48
|
const taggedResult = unwrapJsonApiResult(result);
|
|
48
49
|
const value = taggedResult ? taggedResult.value : result;
|
|
@@ -53,7 +54,124 @@ function resolveAssistantResultValue(result) {
|
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function
|
|
57
|
+
function createProjectionRecordSchema(recordSchema, {
|
|
58
|
+
lookupContainerKey = "",
|
|
59
|
+
relationshipEntries = []
|
|
60
|
+
} = {}) {
|
|
61
|
+
const definitions = recordSchema?.getFieldDefinitions?.();
|
|
62
|
+
if (!isRecord(definitions)) {
|
|
63
|
+
return recordSchema;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fields = Object.fromEntries(
|
|
67
|
+
Object.entries(definitions).map(([fieldKey, fieldDefinition]) => [
|
|
68
|
+
fieldKey,
|
|
69
|
+
{
|
|
70
|
+
...fieldDefinition,
|
|
71
|
+
required: fieldKey === "id" && fieldDefinition?.required === true
|
|
72
|
+
}
|
|
73
|
+
])
|
|
74
|
+
);
|
|
75
|
+
if (lookupContainerKey && isRecord(fields[lookupContainerKey])) {
|
|
76
|
+
const createRelatedRecordSchema = (entry) => createSchema({
|
|
77
|
+
id: {
|
|
78
|
+
type: "string",
|
|
79
|
+
required: true
|
|
80
|
+
},
|
|
81
|
+
...(entry.labelKey && entry.labelKey !== "id"
|
|
82
|
+
? {
|
|
83
|
+
[entry.labelKey]: {
|
|
84
|
+
type: "string",
|
|
85
|
+
required: false,
|
|
86
|
+
nullable: true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
: {})
|
|
90
|
+
});
|
|
91
|
+
const lookupFields = Object.fromEntries(
|
|
92
|
+
relationshipEntries.map((entry) => [
|
|
93
|
+
entry.relationshipName,
|
|
94
|
+
entry.many === true
|
|
95
|
+
? {
|
|
96
|
+
type: "array",
|
|
97
|
+
required: false,
|
|
98
|
+
items: {
|
|
99
|
+
type: "object",
|
|
100
|
+
schema: createRelatedRecordSchema(entry),
|
|
101
|
+
additionalProperties: true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
: {
|
|
105
|
+
type: "object",
|
|
106
|
+
required: false,
|
|
107
|
+
schema: createRelatedRecordSchema(entry),
|
|
108
|
+
additionalProperties: true
|
|
109
|
+
}
|
|
110
|
+
])
|
|
111
|
+
);
|
|
112
|
+
fields[lookupContainerKey] = {
|
|
113
|
+
...fields[lookupContainerKey],
|
|
114
|
+
type: "object",
|
|
115
|
+
required: false,
|
|
116
|
+
...(Object.keys(lookupFields).length > 0 ? { schema: createSchema(lookupFields) } : {}),
|
|
117
|
+
additionalProperties: true
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return createSchema(fields);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createProjectionOutputDefinition(output, operation, relationshipEntries, lookupContainerKey) {
|
|
125
|
+
if (!output || (operation !== "list" && operation !== "view")) {
|
|
126
|
+
return output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (operation === "view") {
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
schema: createProjectionRecordSchema(output.schema, { lookupContainerKey, relationshipEntries }),
|
|
132
|
+
mode: "replace"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const listFields = resolveSchemaFieldDefinitions(output);
|
|
137
|
+
const items = listFields.items;
|
|
138
|
+
if (!isRecord(items) || items.type !== "array" || typeof items.items?.getFieldDefinitions !== "function") {
|
|
139
|
+
return output;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
schema: createSchema({
|
|
144
|
+
...listFields,
|
|
145
|
+
items: {
|
|
146
|
+
...items,
|
|
147
|
+
items: createProjectionRecordSchema(items.items, { lookupContainerKey, relationshipEntries })
|
|
148
|
+
}
|
|
149
|
+
}),
|
|
150
|
+
mode: "replace"
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey) {
|
|
155
|
+
const lookupFieldMap = Object.fromEntries(
|
|
156
|
+
relationshipEntries
|
|
157
|
+
.filter((entry) => entry.many !== true)
|
|
158
|
+
.map((entry) => [entry.relationshipName, entry.attributeKey])
|
|
159
|
+
);
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
kind: "jsonapi-resource",
|
|
162
|
+
responseType: resource.namespace,
|
|
163
|
+
responseKind: operation === "list" ? "collection" : "record",
|
|
164
|
+
...(lookupContainerKey ? { lookupContainerKey } : {}),
|
|
165
|
+
...(Object.keys(lookupFieldMap).length > 0 ? { lookupFieldMap } : {})
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function transformCrudAssistantResult(operation, result, {
|
|
170
|
+
input = {},
|
|
171
|
+
resource,
|
|
172
|
+
relationshipEntries = [],
|
|
173
|
+
lookupContainerKey = ""
|
|
174
|
+
} = {}) {
|
|
57
175
|
if (operation === "delete") {
|
|
58
176
|
const resolved = resolveAssistantResultValue(result);
|
|
59
177
|
if (isRecord(resolved.value) && resolved.value.deleted === true && resolved.value.id != null) {
|
|
@@ -68,9 +186,13 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
|
|
|
68
186
|
const resolved = resolveAssistantResultValue(result);
|
|
69
187
|
if (operation === "list") {
|
|
70
188
|
if (resolved.document.kind === "collection") {
|
|
189
|
+
const decoded = decodeJsonApiResourceResponse(
|
|
190
|
+
resolved.value,
|
|
191
|
+
createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
|
|
192
|
+
);
|
|
71
193
|
return {
|
|
72
|
-
items:
|
|
73
|
-
nextCursor:
|
|
194
|
+
items: decoded.items,
|
|
195
|
+
nextCursor: normalizeOptionalCursor(decoded.nextCursor)
|
|
74
196
|
};
|
|
75
197
|
}
|
|
76
198
|
if (Array.isArray(resolved.value)) {
|
|
@@ -89,29 +211,93 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
|
|
|
89
211
|
}
|
|
90
212
|
|
|
91
213
|
if (resolved.document.kind === "resource") {
|
|
92
|
-
return
|
|
214
|
+
return decodeJsonApiResourceResponse(
|
|
215
|
+
resolved.value,
|
|
216
|
+
createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
|
|
217
|
+
);
|
|
93
218
|
}
|
|
94
219
|
return resolved.value;
|
|
95
220
|
}
|
|
96
221
|
|
|
222
|
+
function createCrudAssistantReadDescription({
|
|
223
|
+
fieldsetContract,
|
|
224
|
+
lookupContainerKey = "",
|
|
225
|
+
namespace = "",
|
|
226
|
+
operation = "list"
|
|
227
|
+
} = {}) {
|
|
228
|
+
const relationshipEntries = fieldsetContract.relationshipEntries;
|
|
229
|
+
const firstRelationship = relationshipEntries[0] || null;
|
|
230
|
+
const includeExample = relationshipEntries.length > 0
|
|
231
|
+
? relationshipEntries.slice(0, 2).map((entry) => entry.relationshipName).join(",")
|
|
232
|
+
: "pet,service";
|
|
233
|
+
const fieldsExample = firstRelationship
|
|
234
|
+
? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
|
|
235
|
+
`\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
|
|
236
|
+
: ` fields can be {\"${namespace}\":[\"id\"]}.`;
|
|
237
|
+
const aliasGuidance = fieldsetContract.aliasMappings
|
|
238
|
+
.map((entry) => `use \"${entry.resourceType}\" instead of \"${entry.alias}\"`)
|
|
239
|
+
.join("; ");
|
|
240
|
+
const fieldsetKeyGuidance = fieldsetContract.resourceTypes.length > 0
|
|
241
|
+
? ` fields keys must be JSON:API resource types: ${fieldsetContract.resourceTypes.map((entry) => `\"${entry}\"`).join(", ")}.` +
|
|
242
|
+
(aliasGuidance ? ` Relationship aliases are invalid fieldset keys; ${aliasGuidance}.` : "")
|
|
243
|
+
: "";
|
|
244
|
+
const primaryFieldGuidance = fieldsetContract.primaryFields.length > 0
|
|
245
|
+
? ` Valid \"${namespace}\" fields: ${fieldsetContract.primaryFields.map((entry) => `\"${entry}\"`).join(", ")}.`
|
|
246
|
+
: "";
|
|
247
|
+
const relationshipGuidance = relationshipEntries.length > 0
|
|
248
|
+
? ` Include relationships: ${relationshipEntries.map((entry) => {
|
|
249
|
+
const lookupPath = lookupContainerKey
|
|
250
|
+
? ` -> ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.${entry.relationshipName}`
|
|
251
|
+
: "";
|
|
252
|
+
return `\"${entry.relationshipName}\" -> resource type \"${entry.relationshipType}\"${lookupPath}`;
|
|
253
|
+
}).join("; ")}.`
|
|
254
|
+
: "";
|
|
255
|
+
|
|
256
|
+
const subject = operation === "list" ? `List ${namespace} records.` : `View a ${namespace} record.`;
|
|
257
|
+
return `${subject} include must be a comma-separated string such as \"${includeExample}\";` +
|
|
258
|
+
`${fieldsExample}${fieldsetKeyGuidance}${primaryFieldGuidance}${relationshipGuidance}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
97
261
|
function createCrudAssistantExtension(resource, namespace, operation) {
|
|
98
262
|
const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
?
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
263
|
+
const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
|
|
264
|
+
const recordOutput = operation === "list"
|
|
265
|
+
? Object.freeze({
|
|
266
|
+
schema: resolveSchemaFieldDefinitions(nativeOutput).items?.items,
|
|
267
|
+
mode: "replace"
|
|
268
|
+
})
|
|
269
|
+
: nativeOutput;
|
|
270
|
+
const relationshipEntries = resolveJsonApiRelationshipEntries(recordOutput);
|
|
271
|
+
const lookupContainerKey = String(resource?.contract?.lookup?.containerKey || "").trim();
|
|
272
|
+
const output = createProjectionOutputDefinition(
|
|
273
|
+
nativeOutput,
|
|
274
|
+
operation,
|
|
275
|
+
relationshipEntries,
|
|
276
|
+
lookupContainerKey
|
|
277
|
+
);
|
|
278
|
+
const actionLabel = operation === "list" || operation === "view"
|
|
279
|
+
? createCrudAssistantReadDescription({
|
|
280
|
+
fieldsetContract: resolveJsonApiFieldsetContract(resource),
|
|
281
|
+
lookupContainerKey,
|
|
282
|
+
namespace,
|
|
283
|
+
operation
|
|
284
|
+
})
|
|
285
|
+
: operation === "create"
|
|
286
|
+
? `Create a ${namespace} record.`
|
|
287
|
+
: operation === "update"
|
|
288
|
+
? `Update a ${namespace} record.`
|
|
289
|
+
: `Delete a ${namespace} record.`;
|
|
109
290
|
|
|
110
291
|
return Object.freeze({
|
|
111
292
|
description: actionLabel,
|
|
112
293
|
output,
|
|
113
294
|
transformResult(result, context) {
|
|
114
|
-
return transformCrudAssistantResult(operation, result,
|
|
295
|
+
return transformCrudAssistantResult(operation, result, {
|
|
296
|
+
...context,
|
|
297
|
+
resource,
|
|
298
|
+
relationshipEntries,
|
|
299
|
+
lookupContainerKey
|
|
300
|
+
});
|
|
115
301
|
}
|
|
116
302
|
});
|
|
117
303
|
}
|
|
@@ -125,9 +311,15 @@ function createActionInput(
|
|
|
125
311
|
) {
|
|
126
312
|
const definitions = scopeInputValidator ? [scopeInputValidator] : [];
|
|
127
313
|
if (operation === "list") {
|
|
128
|
-
definitions.push(...createStandardCrudListQueryValidators({
|
|
314
|
+
definitions.push(...createStandardCrudListQueryValidators({
|
|
315
|
+
resource,
|
|
316
|
+
listFilterQueryValidator,
|
|
317
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
318
|
+
}));
|
|
129
319
|
} else if (operation === "view") {
|
|
130
|
-
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators(
|
|
320
|
+
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
|
|
321
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
322
|
+
}));
|
|
131
323
|
} else if (operation === "create") {
|
|
132
324
|
definitions.push(operationInputs.create || resource.operations.create.body);
|
|
133
325
|
} 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({
|
|
@@ -19,29 +20,96 @@ const lookupIncludeQueryValidator = Object.freeze({
|
|
|
19
20
|
schema: createSchema({
|
|
20
21
|
include: {
|
|
21
22
|
type: "string",
|
|
22
|
-
required: false
|
|
23
|
+
required: false,
|
|
24
|
+
messages: {
|
|
25
|
+
default: "include expects a comma-separated string such as \"pet,service\"."
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
}),
|
|
25
29
|
mode: "patch"
|
|
26
30
|
});
|
|
27
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
|
+
|
|
28
54
|
const jsonApiFieldsetsQueryValidator = Object.freeze({
|
|
29
55
|
schema: createSchema({
|
|
30
56
|
fields: {
|
|
31
57
|
type: "object",
|
|
32
58
|
required: false,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
minLength: 1
|
|
38
|
-
}
|
|
39
|
-
}
|
|
59
|
+
messages: {
|
|
60
|
+
default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
|
|
61
|
+
},
|
|
62
|
+
values: jsonApiFieldsetValueDefinition
|
|
40
63
|
}
|
|
41
64
|
}),
|
|
42
65
|
mode: "patch"
|
|
43
66
|
});
|
|
44
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
|
+
|
|
45
113
|
function resolveCrudListUsesOrderedCursor(list = {}) {
|
|
46
114
|
const entries = Array.isArray(list?.orderBy)
|
|
47
115
|
? list.orderBy
|
|
@@ -109,7 +177,8 @@ function createStandardCrudListQueryValidators({
|
|
|
109
177
|
resource = {},
|
|
110
178
|
listFilterQueryValidator = null,
|
|
111
179
|
searchQueryValidator = listSearchQueryValidator,
|
|
112
|
-
includeQueryValidator = lookupIncludeQueryValidator
|
|
180
|
+
includeQueryValidator = lookupIncludeQueryValidator,
|
|
181
|
+
fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
|
|
113
182
|
} = {}) {
|
|
114
183
|
const resolvedListFilterQueryValidator =
|
|
115
184
|
listFilterQueryValidator
|
|
@@ -126,21 +195,23 @@ function createStandardCrudListQueryValidators({
|
|
|
126
195
|
? [resolvedListFilterQueryValidator]
|
|
127
196
|
: []),
|
|
128
197
|
includeQueryValidator,
|
|
129
|
-
|
|
198
|
+
fieldsetsQueryValidator
|
|
130
199
|
];
|
|
131
200
|
}
|
|
132
201
|
|
|
133
202
|
function createStandardCrudViewQueryValidators({
|
|
134
|
-
includeQueryValidator = lookupIncludeQueryValidator
|
|
203
|
+
includeQueryValidator = lookupIncludeQueryValidator,
|
|
204
|
+
fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
|
|
135
205
|
} = {}) {
|
|
136
206
|
return [
|
|
137
207
|
includeQueryValidator,
|
|
138
|
-
|
|
208
|
+
fieldsetsQueryValidator
|
|
139
209
|
];
|
|
140
210
|
}
|
|
141
211
|
|
|
142
212
|
export {
|
|
143
213
|
createCrudCursorPaginationQueryValidator,
|
|
214
|
+
createJsonApiFieldsetsQueryValidator,
|
|
144
215
|
listSearchQueryValidator,
|
|
145
216
|
lookupIncludeQueryValidator,
|
|
146
217
|
jsonApiFieldsetsQueryValidator,
|
|
@@ -6,84 +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
|
-
required: normalizedFieldDefinition.required === true,
|
|
48
|
-
nullable: normalizedFieldDefinition.nullable === true
|
|
49
|
-
}));
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const relation = isRecord(normalizedFieldDefinition.relation)
|
|
54
|
-
? normalizedFieldDefinition.relation
|
|
55
|
-
: {};
|
|
56
|
-
if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const collectionRelationshipType = resolveCrudResourceScopeName(
|
|
61
|
-
relation.target || relation.targetResource || relation.namespace || relation.apiPath
|
|
62
|
-
);
|
|
63
|
-
if (!collectionRelationshipType) {
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const collectionRelationshipName = String(
|
|
68
|
-
relation.as || normalizedFieldDefinition.as || fieldKey || ""
|
|
69
|
-
).trim();
|
|
70
|
-
if (!collectionRelationshipName) {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
entries.push(Object.freeze({
|
|
75
|
-
attributeKey: fieldKey,
|
|
76
|
-
relationshipName: collectionRelationshipName,
|
|
77
|
-
relationshipType: collectionRelationshipType,
|
|
78
|
-
many: true,
|
|
79
|
-
required: normalizedFieldDefinition.required === true,
|
|
80
|
-
nullable: normalizedFieldDefinition.nullable === true
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return Object.freeze(entries);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
21
|
function readOwnValue(source = {}, key = "") {
|
|
88
22
|
if (isRecord(source) && Object.hasOwn(source, key)) {
|
|
89
23
|
return {
|
|
@@ -574,4 +508,4 @@ function createCrudJsonApiRouteContracts({
|
|
|
574
508
|
});
|
|
575
509
|
}
|
|
576
510
|
|
|
577
|
-
export { createCrudJsonApiRouteContracts };
|
|
511
|
+
export { createCrudJsonApiRouteContracts, resolveJsonApiRelationshipEntries };
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createServiceToolCatalog } from "@jskit-ai/assistant-core/server";
|
|
4
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
5
|
+
import {
|
|
6
|
+
createSchema,
|
|
7
|
+
validateSchemaPayload
|
|
8
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
9
|
+
import { returnJsonApiDocument } from "@jskit-ai/http-runtime/shared";
|
|
10
|
+
import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
|
|
11
|
+
import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
|
|
12
|
+
|
|
13
|
+
const BOOKINGS = Object.freeze([
|
|
14
|
+
Object.freeze({ id: "41", petId: "7", summary: "Wash and trim" }),
|
|
15
|
+
Object.freeze({ id: "42", petId: "8", summary: "Nail trim" })
|
|
16
|
+
]);
|
|
17
|
+
const PETS = Object.freeze({
|
|
18
|
+
"7": Object.freeze({ id: "7", name: "Fido", species: "dog" }),
|
|
19
|
+
"8": Object.freeze({ id: "8", name: "Mabel", species: "cat" })
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function createBookingsResource() {
|
|
23
|
+
return defineCrudResource({
|
|
24
|
+
namespace: "bookings",
|
|
25
|
+
tableName: "bookings",
|
|
26
|
+
crudOperations: ["list", "view"],
|
|
27
|
+
contract: {
|
|
28
|
+
lookup: {
|
|
29
|
+
containerKey: "lookups"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
schema: {
|
|
33
|
+
petId: {
|
|
34
|
+
type: "id",
|
|
35
|
+
required: true,
|
|
36
|
+
belongsTo: "pets",
|
|
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
|
+
},
|
|
48
|
+
summary: {
|
|
49
|
+
type: "string",
|
|
50
|
+
required: true,
|
|
51
|
+
operations: {
|
|
52
|
+
output: { required: true }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function selectFields(source, selectedFields = null) {
|
|
60
|
+
if (!Array.isArray(selectedFields)) {
|
|
61
|
+
return { ...source };
|
|
62
|
+
}
|
|
63
|
+
return Object.fromEntries(
|
|
64
|
+
selectedFields
|
|
65
|
+
.filter((field) => Object.hasOwn(source, field))
|
|
66
|
+
.map((field) => [field, source[field]])
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createBookingDocument(query = {}) {
|
|
71
|
+
const limit = Math.min(Number(query.limit) || BOOKINGS.length, BOOKINGS.length);
|
|
72
|
+
const selectedBookings = query.fields?.bookings || null;
|
|
73
|
+
const selectedPets = query.fields?.pets || null;
|
|
74
|
+
const includesPet = String(query.include || "")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((entry) => entry.trim())
|
|
77
|
+
.includes("pet");
|
|
78
|
+
const rows = BOOKINGS.slice(0, limit);
|
|
79
|
+
const data = rows.map((booking) => {
|
|
80
|
+
const includePetLinkage = !selectedBookings || selectedBookings.includes("petId");
|
|
81
|
+
return {
|
|
82
|
+
type: "bookings",
|
|
83
|
+
id: booking.id,
|
|
84
|
+
attributes: selectFields({ summary: booking.summary }, selectedBookings),
|
|
85
|
+
...(includePetLinkage
|
|
86
|
+
? {
|
|
87
|
+
relationships: {
|
|
88
|
+
pet: {
|
|
89
|
+
data: { type: "pets", id: booking.petId }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
: {})
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
const included = includesPet
|
|
97
|
+
? rows.map((booking) => ({
|
|
98
|
+
type: "pets",
|
|
99
|
+
id: booking.petId,
|
|
100
|
+
attributes: selectFields({
|
|
101
|
+
name: PETS[booking.petId].name,
|
|
102
|
+
species: PETS[booking.petId].species
|
|
103
|
+
}, selectedPets)
|
|
104
|
+
}))
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
data,
|
|
109
|
+
...(included.length > 0 ? { included } : {}),
|
|
110
|
+
meta: {
|
|
111
|
+
page: {
|
|
112
|
+
nextCursor: limit < BOOKINGS.length ? `after-${rows.at(-1)?.id}` : null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createFixture() {
|
|
119
|
+
const resource = createBookingsResource();
|
|
120
|
+
const calls = [];
|
|
121
|
+
const service = {
|
|
122
|
+
async queryDocuments(query, { context }) {
|
|
123
|
+
calls.push({ query, context });
|
|
124
|
+
return returnJsonApiDocument(createBookingDocument(query));
|
|
125
|
+
},
|
|
126
|
+
async getDocumentById() {
|
|
127
|
+
return returnJsonApiDocument({ data: createBookingDocument().data[0] });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const definitions = createCrudJsonApiActions({
|
|
131
|
+
namespace: "bookings",
|
|
132
|
+
resource,
|
|
133
|
+
service,
|
|
134
|
+
surface: "admin",
|
|
135
|
+
operations: ["list", "view"],
|
|
136
|
+
scopeInputValidator: Object.freeze({
|
|
137
|
+
schema: createSchema({
|
|
138
|
+
workspaceSlug: { type: "string", required: true }
|
|
139
|
+
}),
|
|
140
|
+
mode: "patch"
|
|
141
|
+
}),
|
|
142
|
+
scopeInputKeys: ["workspaceSlug"],
|
|
143
|
+
permissionForOperation: () => ({
|
|
144
|
+
require: "all",
|
|
145
|
+
permissions: ["bookings.list"]
|
|
146
|
+
})
|
|
147
|
+
});
|
|
148
|
+
const actions = createActionCatalogue();
|
|
149
|
+
actions.register({
|
|
150
|
+
contributorId: "test.generated-bookings",
|
|
151
|
+
domain: "bookings",
|
|
152
|
+
actions: definitions
|
|
153
|
+
});
|
|
154
|
+
const catalog = createServiceToolCatalog(actions, { maxDirectTools: 0 });
|
|
155
|
+
const context = Object.freeze({
|
|
156
|
+
actor: Object.freeze({ id: "user-7" }),
|
|
157
|
+
permissions: Object.freeze(["bookings.list"]),
|
|
158
|
+
surface: "admin",
|
|
159
|
+
workspace: Object.freeze({ slug: "north-clinic" })
|
|
160
|
+
});
|
|
161
|
+
return { actions, calls, catalog, context, definitions, resource };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function loadListContract(fixture, context = fixture.context) {
|
|
165
|
+
const toolSet = fixture.catalog.resolveToolSet(context);
|
|
166
|
+
const search = await fixture.catalog.executeToolCall({
|
|
167
|
+
toolName: "assistant_action_search",
|
|
168
|
+
argumentsText: JSON.stringify({ query: "bookings list" }),
|
|
169
|
+
context,
|
|
170
|
+
toolSet
|
|
171
|
+
});
|
|
172
|
+
const contract = await fixture.catalog.executeToolCall({
|
|
173
|
+
toolName: "assistant_action_contract",
|
|
174
|
+
argumentsText: JSON.stringify({ actionId: "crud.bookings.list", version: 1 }),
|
|
175
|
+
context,
|
|
176
|
+
toolSet
|
|
177
|
+
});
|
|
178
|
+
return { contract, search, toolSet };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function executeList(fixture, toolSet, input, context = fixture.context) {
|
|
182
|
+
return fixture.catalog.executeToolCall({
|
|
183
|
+
toolName: "assistant_action_execute",
|
|
184
|
+
argumentsText: JSON.stringify({
|
|
185
|
+
actionId: "crud.bookings.list",
|
|
186
|
+
version: 1,
|
|
187
|
+
input
|
|
188
|
+
}),
|
|
189
|
+
context,
|
|
190
|
+
toolSet
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveLocalSchemaReference(schema, node) {
|
|
195
|
+
const reference = node?.allOf?.[0]?.$ref;
|
|
196
|
+
assert.match(reference || "", /^#\/definitions\//u);
|
|
197
|
+
return schema.definitions[reference.slice("#/definitions/".length)];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test("generated CRUD list contracts conform through native assistant discovery and execution", async (t) => {
|
|
201
|
+
const fixture = createFixture();
|
|
202
|
+
const { contract, search, toolSet } = await loadListContract(fixture);
|
|
203
|
+
const listDefinition = fixture.definitions.find((entry) => entry.id === "crud.bookings.list");
|
|
204
|
+
|
|
205
|
+
assert.equal(search.ok, true);
|
|
206
|
+
assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
|
|
207
|
+
assert.equal(contract.ok, true);
|
|
208
|
+
assert.equal(contract.result.inputSchema.properties.include.type, "string");
|
|
209
|
+
const fieldsetInputSchema = resolveLocalSchemaReference(
|
|
210
|
+
contract.result.inputSchema,
|
|
211
|
+
contract.result.inputSchema.properties.fields
|
|
212
|
+
);
|
|
213
|
+
assert.deepEqual(Object.keys(fieldsetInputSchema.properties), ["bookings", "pets"]);
|
|
214
|
+
assert.equal(fieldsetInputSchema.additionalProperties, false);
|
|
215
|
+
assert.deepEqual(fieldsetInputSchema.properties.bookings.items.enum, ["id", "petId", "summary"]);
|
|
216
|
+
assert.equal(Object.hasOwn(fieldsetInputSchema.properties.pets.items, "enum"), false);
|
|
217
|
+
assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
|
|
218
|
+
assert.match(contract.result.description, /include must be a comma-separated string/u);
|
|
219
|
+
assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
|
|
220
|
+
assert.match(contract.result.description, /fields keys must be JSON:API resource types: "bookings", "pets"/u);
|
|
221
|
+
assert.match(contract.result.description, /use "pets" instead of "pet"/u);
|
|
222
|
+
assert.match(
|
|
223
|
+
contract.result.description,
|
|
224
|
+
/"pet" -> resource type "pets" -> items\[\]\.lookups\.pet/u
|
|
225
|
+
);
|
|
226
|
+
const primaryOutputSchema = resolveLocalSchemaReference(
|
|
227
|
+
contract.result.outputSchema,
|
|
228
|
+
contract.result.outputSchema.properties.items.items
|
|
229
|
+
);
|
|
230
|
+
const lookupOutputSchema = resolveLocalSchemaReference(
|
|
231
|
+
contract.result.outputSchema,
|
|
232
|
+
primaryOutputSchema.properties.lookups
|
|
233
|
+
);
|
|
234
|
+
const petOutputSchema = resolveLocalSchemaReference(
|
|
235
|
+
contract.result.outputSchema,
|
|
236
|
+
lookupOutputSchema.properties.pet
|
|
237
|
+
);
|
|
238
|
+
assert.ok(petOutputSchema.properties.name.anyOf.some((entry) => entry.type === "string"));
|
|
239
|
+
|
|
240
|
+
await t.test("full list without fields or include", async () => {
|
|
241
|
+
const response = await executeList(fixture, toolSet, { limit: 5 });
|
|
242
|
+
assert.equal(response.ok, true);
|
|
243
|
+
assert.deepEqual(response.result.result.items, [
|
|
244
|
+
{ id: "41", petId: 7, summary: "Wash and trim" },
|
|
245
|
+
{ id: "42", petId: 8, summary: "Nail trim" }
|
|
246
|
+
]);
|
|
247
|
+
assert.equal(response.result.result.nextCursor, null);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await t.test("sparse primary fields only", async () => {
|
|
251
|
+
const response = await executeList(fixture, toolSet, {
|
|
252
|
+
fields: { bookings: ["petId"] },
|
|
253
|
+
limit: 5
|
|
254
|
+
});
|
|
255
|
+
assert.equal(response.ok, true);
|
|
256
|
+
assert.deepEqual(response.result.result.items, [
|
|
257
|
+
{ id: "41", petId: 7 },
|
|
258
|
+
{ id: "42", petId: 8 }
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await t.test("included relationship without sparse fields", async () => {
|
|
263
|
+
const response = await executeList(fixture, toolSet, { include: "pet", limit: 5 });
|
|
264
|
+
assert.equal(response.ok, true);
|
|
265
|
+
assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
|
|
266
|
+
assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
|
|
267
|
+
assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await t.test("included relationship plus sparse primary and related fields", async () => {
|
|
271
|
+
const response = await executeList(fixture, toolSet, {
|
|
272
|
+
workspaceSlug: "model-controlled-workspace",
|
|
273
|
+
include: "pet",
|
|
274
|
+
fields: { bookings: ["petId"], pets: ["name"] },
|
|
275
|
+
limit: 5
|
|
276
|
+
});
|
|
277
|
+
assert.equal(response.ok, true);
|
|
278
|
+
assert.notEqual(response.error?.code, "assistant_tool_output_invalid");
|
|
279
|
+
assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
|
|
280
|
+
{
|
|
281
|
+
id: "41",
|
|
282
|
+
petId: 7,
|
|
283
|
+
lookups: {
|
|
284
|
+
petId: { id: "7", name: "Fido" },
|
|
285
|
+
pet: { id: "7", name: "Fido" }
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: "42",
|
|
290
|
+
petId: 8,
|
|
291
|
+
lookups: {
|
|
292
|
+
petId: { id: "8", name: "Mabel" },
|
|
293
|
+
pet: { id: "8", name: "Mabel" }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
]);
|
|
297
|
+
assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
|
|
298
|
+
assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
|
|
301
|
+
phase: "output"
|
|
302
|
+
}),
|
|
303
|
+
response.result.result
|
|
304
|
+
);
|
|
305
|
+
assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
|
|
306
|
+
assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await t.test("limit and cursor truncation remain bounded", async () => {
|
|
310
|
+
const response = await executeList(fixture, toolSet, { limit: 1 });
|
|
311
|
+
assert.equal(response.ok, true);
|
|
312
|
+
assert.equal(response.result.result.items.length, 1);
|
|
313
|
+
assert.equal(response.result.result.nextCursor, "after-41");
|
|
314
|
+
assert.equal(fixture.calls.at(-1).query.limit, 1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await t.test("invalid array-shaped include gives an actionable validation result", async () => {
|
|
318
|
+
const response = await executeList(fixture, toolSet, { include: ["pet"] });
|
|
319
|
+
assert.equal(response.ok, false);
|
|
320
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
321
|
+
assert.equal(response.error.status, 400);
|
|
322
|
+
assert.match(response.error.message, /include expects a comma-separated string such as "pet,service"/u);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await t.test("invalid array-shaped fields gives an actionable validation result", async () => {
|
|
326
|
+
const response = await executeList(fixture, toolSet, { fields: ["pet.name"] });
|
|
327
|
+
assert.equal(response.ok, false);
|
|
328
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
329
|
+
assert.equal(response.error.status, 400);
|
|
330
|
+
assert.match(
|
|
331
|
+
response.error.message,
|
|
332
|
+
/fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await t.test("relationship aliases are rejected as sparse-fieldset resource keys", async () => {
|
|
337
|
+
const callCount = fixture.calls.length;
|
|
338
|
+
const response = await executeList(fixture, toolSet, {
|
|
339
|
+
include: "pet",
|
|
340
|
+
fields: { pet: ["name"] },
|
|
341
|
+
limit: 3
|
|
342
|
+
});
|
|
343
|
+
assert.equal(response.ok, false);
|
|
344
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
345
|
+
assert.equal(response.error.status, 400);
|
|
346
|
+
assert.match(response.error.message, /fields\.pet: fields keys must be JSON:API resource types/u);
|
|
347
|
+
assert.match(response.error.message, /Allowed keys: "bookings", "pets"/u);
|
|
348
|
+
assert.match(response.error.message, /use "pets" instead of "pet"/u);
|
|
349
|
+
assert.equal(fixture.calls.length, callCount);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await t.test("permission denial removes the generated action from native discovery", async () => {
|
|
353
|
+
const deniedContext = {
|
|
354
|
+
...fixture.context,
|
|
355
|
+
permissions: []
|
|
356
|
+
};
|
|
357
|
+
const deniedToolSet = fixture.catalog.resolveToolSet(deniedContext);
|
|
358
|
+
assert.deepEqual(deniedToolSet.tools, []);
|
|
359
|
+
assert.deepEqual(await fixture.catalog.executeToolCall({
|
|
360
|
+
toolName: "crud_bookings_list",
|
|
361
|
+
argumentsText: "{}",
|
|
362
|
+
context: deniedContext,
|
|
363
|
+
toolSet: deniedToolSet
|
|
364
|
+
}), {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: {
|
|
367
|
+
code: "assistant_tool_unknown",
|
|
368
|
+
message: "Unknown tool."
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -227,11 +227,18 @@ test("generated CRUD actions expose truthful assistant contracts without changin
|
|
|
227
227
|
const byOperation = new Map(actions.map((entry) => [entry.id.split(".").at(-1), entry]));
|
|
228
228
|
|
|
229
229
|
assert.ok(actions.every((entry) => entry.output === null));
|
|
230
|
-
assert.
|
|
231
|
-
assert.
|
|
230
|
+
assert.notEqual(byOperation.get("list").extensions.assistant.output, resource.operations.list.output);
|
|
231
|
+
assert.notEqual(byOperation.get("view").extensions.assistant.output, resource.operations.view.output);
|
|
232
232
|
assert.equal(byOperation.get("create").extensions.assistant.output, resource.operations.create.output);
|
|
233
233
|
assert.equal(byOperation.get("update").extensions.assistant.output, resource.operations.patch.output);
|
|
234
234
|
assert.equal(byOperation.get("delete").extensions.assistant.output, resource.operations.delete.output);
|
|
235
|
+
const sparseListContract = byOperation.get("list").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" });
|
|
236
|
+
const sparseListItemContract = Object.values(sparseListContract.definitions)[0];
|
|
237
|
+
assert.deepEqual(sparseListItemContract.required, ["id"]);
|
|
238
|
+
assert.deepEqual(
|
|
239
|
+
byOperation.get("view").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" }).required,
|
|
240
|
+
["id"]
|
|
241
|
+
);
|
|
235
242
|
|
|
236
243
|
const transform = async (operation, input = {}) => {
|
|
237
244
|
const definition = byOperation.get(operation);
|
|
@@ -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
|
|