@jskit-ai/crud-core 0.1.179 → 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 +6 -6
- package/patterns/json-api-resource-package/example/packages/books/package.json +2 -2
- package/src/server/jsonApiModule/actions.js +65 -26
- 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 +31 -3
- 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,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.169",
|
|
27
|
+
"@jskit-ai/json-rest-api-core": "0.1.113",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.111",
|
|
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.167",
|
|
49
|
+
"@jskit-ai/kernel": "0.1.169"
|
|
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.180",
|
|
12
|
+
"@jskit-ai/resource-crud-core": "0.1.111"
|
|
13
13
|
},
|
|
14
14
|
"jskit": {
|
|
15
15
|
"kind": "runtime",
|
|
@@ -10,8 +10,13 @@ import {
|
|
|
10
10
|
unwrapJsonApiResult
|
|
11
11
|
} from "@jskit-ai/http-runtime/shared";
|
|
12
12
|
import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
|
|
13
|
-
import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
|
|
14
13
|
import {
|
|
14
|
+
resolveJsonApiFieldsetContract,
|
|
15
|
+
resolveJsonApiRelationshipEntries,
|
|
16
|
+
resolveSchemaFieldDefinitions
|
|
17
|
+
} from "../jsonApiResourceContract.js";
|
|
18
|
+
import {
|
|
19
|
+
createJsonApiFieldsetsQueryValidator,
|
|
15
20
|
createStandardCrudListQueryValidators,
|
|
16
21
|
createStandardCrudViewQueryValidators
|
|
17
22
|
} from "../listQueryValidators.js";
|
|
@@ -49,11 +54,6 @@ function resolveAssistantResultValue(result) {
|
|
|
49
54
|
};
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
function resolveSchemaFieldDefinitions(definition = null) {
|
|
53
|
-
const definitions = definition?.schema?.getFieldDefinitions?.();
|
|
54
|
-
return isRecord(definitions) ? definitions : {};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
57
|
function createProjectionRecordSchema(recordSchema, {
|
|
58
58
|
lookupContainerKey = "",
|
|
59
59
|
relationshipEntries = []
|
|
@@ -219,6 +219,45 @@ function transformCrudAssistantResult(operation, result, {
|
|
|
219
219
|
return resolved.value;
|
|
220
220
|
}
|
|
221
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
|
+
|
|
222
261
|
function createCrudAssistantExtension(resource, namespace, operation) {
|
|
223
262
|
const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
|
|
224
263
|
const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
|
|
@@ -236,24 +275,18 @@ function createCrudAssistantExtension(resource, namespace, operation) {
|
|
|
236
275
|
relationshipEntries,
|
|
237
276
|
lookupContainerKey
|
|
238
277
|
);
|
|
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.`;
|
|
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.`;
|
|
257
290
|
|
|
258
291
|
return Object.freeze({
|
|
259
292
|
description: actionLabel,
|
|
@@ -278,9 +311,15 @@ function createActionInput(
|
|
|
278
311
|
) {
|
|
279
312
|
const definitions = scopeInputValidator ? [scopeInputValidator] : [];
|
|
280
313
|
if (operation === "list") {
|
|
281
|
-
definitions.push(...createStandardCrudListQueryValidators({
|
|
314
|
+
definitions.push(...createStandardCrudListQueryValidators({
|
|
315
|
+
resource,
|
|
316
|
+
listFilterQueryValidator,
|
|
317
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
318
|
+
}));
|
|
282
319
|
} else if (operation === "view") {
|
|
283
|
-
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators(
|
|
320
|
+
definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
|
|
321
|
+
fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
|
|
322
|
+
}));
|
|
284
323
|
} else if (operation === "create") {
|
|
285
324
|
definitions.push(operationInputs.create || resource.operations.create.body);
|
|
286
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({
|
|
@@ -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 {
|
|
@@ -206,11 +206,23 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
206
206
|
assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
|
|
207
207
|
assert.equal(contract.ok, true);
|
|
208
208
|
assert.equal(contract.result.inputSchema.properties.include.type, "string");
|
|
209
|
-
|
|
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);
|
|
210
217
|
assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
|
|
211
218
|
assert.match(contract.result.description, /include must be a comma-separated string/u);
|
|
212
219
|
assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
|
|
213
|
-
assert.match(contract.result.description, /
|
|
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
|
+
);
|
|
214
226
|
const primaryOutputSchema = resolveLocalSchemaReference(
|
|
215
227
|
contract.result.outputSchema,
|
|
216
228
|
contract.result.outputSchema.properties.items.items
|
|
@@ -317,10 +329,26 @@ test("generated CRUD list contracts conform through native assistant discovery a
|
|
|
317
329
|
assert.equal(response.error.status, 400);
|
|
318
330
|
assert.match(
|
|
319
331
|
response.error.message,
|
|
320
|
-
/fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
|
|
332
|
+
/fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
|
|
321
333
|
);
|
|
322
334
|
});
|
|
323
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
|
+
|
|
324
352
|
await t.test("permission denial removes the generated action from native discovery", async () => {
|
|
325
353
|
const deniedContext = {
|
|
326
354
|
...fixture.context,
|
|
@@ -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
|
|