@jskit-ai/crud-core 0.1.178 → 0.1.179
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 +168 -15
- package/src/server/listQueryValidators.js +7 -1
- package/src/server/routeContracts.js +3 -1
- package/test/assistantActionConformance.test.js +344 -0
- package/test/defineCrudJsonApiFeature.test.js +9 -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.179",
|
|
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.168",
|
|
27
|
+
"@jskit-ai/json-rest-api-core": "0.1.112",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.110",
|
|
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.166",
|
|
49
|
+
"@jskit-ai/kernel": "0.1.168"
|
|
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.179",
|
|
12
|
+
"@jskit-ai/resource-crud-core": "0.1.110"
|
|
13
13
|
},
|
|
14
14
|
"jskit": {
|
|
15
15
|
"kind": "runtime",
|
|
@@ -1,14 +1,16 @@
|
|
|
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";
|
|
13
|
+
import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
|
|
12
14
|
import {
|
|
13
15
|
createStandardCrudListQueryValidators,
|
|
14
16
|
createStandardCrudViewQueryValidators
|
|
@@ -37,12 +39,6 @@ function normalizeOptionalCursor(value) {
|
|
|
37
39
|
return normalized || null;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
function resolveDocumentNextCursor(document = {}) {
|
|
41
|
-
return normalizeOptionalCursor(
|
|
42
|
-
document?.meta?.page?.nextCursor ?? document?.meta?.pagination?.cursor?.next
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
function resolveAssistantResultValue(result) {
|
|
47
43
|
const taggedResult = unwrapJsonApiResult(result);
|
|
48
44
|
const value = taggedResult ? taggedResult.value : result;
|
|
@@ -53,7 +49,129 @@ function resolveAssistantResultValue(result) {
|
|
|
53
49
|
};
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
function
|
|
52
|
+
function resolveSchemaFieldDefinitions(definition = null) {
|
|
53
|
+
const definitions = definition?.schema?.getFieldDefinitions?.();
|
|
54
|
+
return isRecord(definitions) ? definitions : {};
|
|
55
|
+
}
|
|
56
|
+
|
|
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,18 +211,44 @@ 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
|
|
|
97
222
|
function createCrudAssistantExtension(resource, namespace, operation) {
|
|
98
223
|
const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
|
|
99
|
-
const
|
|
224
|
+
const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
|
|
225
|
+
const recordOutput = operation === "list"
|
|
226
|
+
? Object.freeze({
|
|
227
|
+
schema: resolveSchemaFieldDefinitions(nativeOutput).items?.items,
|
|
228
|
+
mode: "replace"
|
|
229
|
+
})
|
|
230
|
+
: nativeOutput;
|
|
231
|
+
const relationshipEntries = resolveJsonApiRelationshipEntries(recordOutput);
|
|
232
|
+
const lookupContainerKey = String(resource?.contract?.lookup?.containerKey || "").trim();
|
|
233
|
+
const output = createProjectionOutputDefinition(
|
|
234
|
+
nativeOutput,
|
|
235
|
+
operation,
|
|
236
|
+
relationshipEntries,
|
|
237
|
+
lookupContainerKey
|
|
238
|
+
);
|
|
239
|
+
const firstRelationship = relationshipEntries[0] || null;
|
|
240
|
+
const fieldsExample = firstRelationship
|
|
241
|
+
? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
|
|
242
|
+
`\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
|
|
243
|
+
: ` fields can be {\"${namespace}\":[\"id\"]}.`;
|
|
244
|
+
const lookupExample = firstRelationship && lookupContainerKey
|
|
245
|
+
? ` Included fields are returned under ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.` +
|
|
246
|
+
`${firstRelationship.relationshipName}.${firstRelationship.labelKey || "<selectedField>"}.`
|
|
247
|
+
: "";
|
|
100
248
|
const actionLabel = operation === "list"
|
|
101
|
-
? `List ${namespace} records
|
|
249
|
+
? `List ${namespace} records. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
|
|
102
250
|
: operation === "view"
|
|
103
|
-
? `View a ${namespace} record
|
|
251
|
+
? `View a ${namespace} record. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
|
|
104
252
|
: operation === "create"
|
|
105
253
|
? `Create a ${namespace} record.`
|
|
106
254
|
: operation === "update"
|
|
@@ -111,7 +259,12 @@ function createCrudAssistantExtension(resource, namespace, operation) {
|
|
|
111
259
|
description: actionLabel,
|
|
112
260
|
output,
|
|
113
261
|
transformResult(result, context) {
|
|
114
|
-
return transformCrudAssistantResult(operation, result,
|
|
262
|
+
return transformCrudAssistantResult(operation, result, {
|
|
263
|
+
...context,
|
|
264
|
+
resource,
|
|
265
|
+
relationshipEntries,
|
|
266
|
+
lookupContainerKey
|
|
267
|
+
});
|
|
115
268
|
}
|
|
116
269
|
});
|
|
117
270
|
}
|
|
@@ -19,7 +19,10 @@ const lookupIncludeQueryValidator = Object.freeze({
|
|
|
19
19
|
schema: createSchema({
|
|
20
20
|
include: {
|
|
21
21
|
type: "string",
|
|
22
|
-
required: false
|
|
22
|
+
required: false,
|
|
23
|
+
messages: {
|
|
24
|
+
default: "include expects a comma-separated string such as \"pet,service\"."
|
|
25
|
+
}
|
|
23
26
|
}
|
|
24
27
|
}),
|
|
25
28
|
mode: "patch"
|
|
@@ -30,6 +33,9 @@ const jsonApiFieldsetsQueryValidator = Object.freeze({
|
|
|
30
33
|
fields: {
|
|
31
34
|
type: "object",
|
|
32
35
|
required: false,
|
|
36
|
+
messages: {
|
|
37
|
+
default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
|
|
38
|
+
},
|
|
33
39
|
values: {
|
|
34
40
|
type: "array",
|
|
35
41
|
items: {
|
|
@@ -44,6 +44,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
|
|
|
44
44
|
attributeKey: fieldKey,
|
|
45
45
|
relationshipName,
|
|
46
46
|
relationshipType,
|
|
47
|
+
labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
|
|
47
48
|
required: normalizedFieldDefinition.required === true,
|
|
48
49
|
nullable: normalizedFieldDefinition.nullable === true
|
|
49
50
|
}));
|
|
@@ -75,6 +76,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
|
|
|
75
76
|
attributeKey: fieldKey,
|
|
76
77
|
relationshipName: collectionRelationshipName,
|
|
77
78
|
relationshipType: collectionRelationshipType,
|
|
79
|
+
labelKey: String(relation.labelKey || "").trim(),
|
|
78
80
|
many: true,
|
|
79
81
|
required: normalizedFieldDefinition.required === true,
|
|
80
82
|
nullable: normalizedFieldDefinition.nullable === true
|
|
@@ -574,4 +576,4 @@ function createCrudJsonApiRouteContracts({
|
|
|
574
576
|
});
|
|
575
577
|
}
|
|
576
578
|
|
|
577
|
-
export { createCrudJsonApiRouteContracts };
|
|
579
|
+
export { createCrudJsonApiRouteContracts, resolveJsonApiRelationshipEntries };
|
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
assert.equal(contract.result.inputSchema.properties.fields.type, "object");
|
|
210
|
+
assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
|
|
211
|
+
assert.match(contract.result.description, /include must be a comma-separated string/u);
|
|
212
|
+
assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
|
|
213
|
+
assert.match(contract.result.description, /items\[\]\.lookups\.pet\.name/u);
|
|
214
|
+
const primaryOutputSchema = resolveLocalSchemaReference(
|
|
215
|
+
contract.result.outputSchema,
|
|
216
|
+
contract.result.outputSchema.properties.items.items
|
|
217
|
+
);
|
|
218
|
+
const lookupOutputSchema = resolveLocalSchemaReference(
|
|
219
|
+
contract.result.outputSchema,
|
|
220
|
+
primaryOutputSchema.properties.lookups
|
|
221
|
+
);
|
|
222
|
+
const petOutputSchema = resolveLocalSchemaReference(
|
|
223
|
+
contract.result.outputSchema,
|
|
224
|
+
lookupOutputSchema.properties.pet
|
|
225
|
+
);
|
|
226
|
+
assert.ok(petOutputSchema.properties.name.anyOf.some((entry) => entry.type === "string"));
|
|
227
|
+
|
|
228
|
+
await t.test("full list without fields or include", async () => {
|
|
229
|
+
const response = await executeList(fixture, toolSet, { limit: 5 });
|
|
230
|
+
assert.equal(response.ok, true);
|
|
231
|
+
assert.deepEqual(response.result.result.items, [
|
|
232
|
+
{ id: "41", petId: 7, summary: "Wash and trim" },
|
|
233
|
+
{ id: "42", petId: 8, summary: "Nail trim" }
|
|
234
|
+
]);
|
|
235
|
+
assert.equal(response.result.result.nextCursor, null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await t.test("sparse primary fields only", async () => {
|
|
239
|
+
const response = await executeList(fixture, toolSet, {
|
|
240
|
+
fields: { bookings: ["petId"] },
|
|
241
|
+
limit: 5
|
|
242
|
+
});
|
|
243
|
+
assert.equal(response.ok, true);
|
|
244
|
+
assert.deepEqual(response.result.result.items, [
|
|
245
|
+
{ id: "41", petId: 7 },
|
|
246
|
+
{ id: "42", petId: 8 }
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await t.test("included relationship without sparse fields", async () => {
|
|
251
|
+
const response = await executeList(fixture, toolSet, { include: "pet", limit: 5 });
|
|
252
|
+
assert.equal(response.ok, true);
|
|
253
|
+
assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
|
|
254
|
+
assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
|
|
255
|
+
assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await t.test("included relationship plus sparse primary and related fields", async () => {
|
|
259
|
+
const response = await executeList(fixture, toolSet, {
|
|
260
|
+
workspaceSlug: "model-controlled-workspace",
|
|
261
|
+
include: "pet",
|
|
262
|
+
fields: { bookings: ["petId"], pets: ["name"] },
|
|
263
|
+
limit: 5
|
|
264
|
+
});
|
|
265
|
+
assert.equal(response.ok, true);
|
|
266
|
+
assert.notEqual(response.error?.code, "assistant_tool_output_invalid");
|
|
267
|
+
assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
|
|
268
|
+
{
|
|
269
|
+
id: "41",
|
|
270
|
+
petId: 7,
|
|
271
|
+
lookups: {
|
|
272
|
+
petId: { id: "7", name: "Fido" },
|
|
273
|
+
pet: { id: "7", name: "Fido" }
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: "42",
|
|
278
|
+
petId: 8,
|
|
279
|
+
lookups: {
|
|
280
|
+
petId: { id: "8", name: "Mabel" },
|
|
281
|
+
pet: { id: "8", name: "Mabel" }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
]);
|
|
285
|
+
assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
|
|
286
|
+
assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
|
|
287
|
+
assert.deepEqual(
|
|
288
|
+
validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
|
|
289
|
+
phase: "output"
|
|
290
|
+
}),
|
|
291
|
+
response.result.result
|
|
292
|
+
);
|
|
293
|
+
assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
|
|
294
|
+
assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await t.test("limit and cursor truncation remain bounded", async () => {
|
|
298
|
+
const response = await executeList(fixture, toolSet, { limit: 1 });
|
|
299
|
+
assert.equal(response.ok, true);
|
|
300
|
+
assert.equal(response.result.result.items.length, 1);
|
|
301
|
+
assert.equal(response.result.result.nextCursor, "after-41");
|
|
302
|
+
assert.equal(fixture.calls.at(-1).query.limit, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await t.test("invalid array-shaped include gives an actionable validation result", async () => {
|
|
306
|
+
const response = await executeList(fixture, toolSet, { include: ["pet"] });
|
|
307
|
+
assert.equal(response.ok, false);
|
|
308
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
309
|
+
assert.equal(response.error.status, 400);
|
|
310
|
+
assert.match(response.error.message, /include expects a comma-separated string such as "pet,service"/u);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await t.test("invalid array-shaped fields gives an actionable validation result", async () => {
|
|
314
|
+
const response = await executeList(fixture, toolSet, { fields: ["pet.name"] });
|
|
315
|
+
assert.equal(response.ok, false);
|
|
316
|
+
assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
|
|
317
|
+
assert.equal(response.error.status, 400);
|
|
318
|
+
assert.match(
|
|
319
|
+
response.error.message,
|
|
320
|
+
/fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await t.test("permission denial removes the generated action from native discovery", async () => {
|
|
325
|
+
const deniedContext = {
|
|
326
|
+
...fixture.context,
|
|
327
|
+
permissions: []
|
|
328
|
+
};
|
|
329
|
+
const deniedToolSet = fixture.catalog.resolveToolSet(deniedContext);
|
|
330
|
+
assert.deepEqual(deniedToolSet.tools, []);
|
|
331
|
+
assert.deepEqual(await fixture.catalog.executeToolCall({
|
|
332
|
+
toolName: "crud_bookings_list",
|
|
333
|
+
argumentsText: "{}",
|
|
334
|
+
context: deniedContext,
|
|
335
|
+
toolSet: deniedToolSet
|
|
336
|
+
}), {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: {
|
|
339
|
+
code: "assistant_tool_unknown",
|
|
340
|
+
message: "Unknown tool."
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -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);
|