@appweaver/core 1.1.6 → 1.2.0
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/cache/eviction/lfu-eviction-index.js +3 -0
- package/export/export-service.d.ts +4 -2
- package/export/export-service.js +27 -17
- package/factory/create-model.js +72 -36
- package/factory/create-service.js +1 -1
- package/package.json +1 -1
- package/resource/index.d.ts +1 -0
- package/resource/index.js +1 -0
- package/resource/resource-schema.d.ts +0 -5
- package/resource/resource-schema.js +25 -10
- package/resource/resource-service.d.ts +214 -36
- package/resource/resource-service.js +247 -404
- package/resource/schemas/index.d.ts +3 -0
- package/resource/schemas/index.js +19 -0
- package/resource/schemas/resource-aggregate-schema.d.ts +54 -0
- package/resource/schemas/resource-aggregate-schema.js +131 -0
- package/resource/schemas/resource-filter-schema.d.ts +39 -0
- package/resource/schemas/resource-filter-schema.js +153 -0
- package/resource/schemas/resource-sort-schema.d.ts +37 -0
- package/resource/schemas/resource-sort-schema.js +114 -0
- package/resource/utils/aggregate-util.d.ts +113 -0
- package/resource/utils/aggregate-util.js +323 -0
- package/resource/utils/filter-util.d.ts +24 -0
- package/resource/utils/filter-util.js +283 -0
- package/resource/utils/index.d.ts +4 -0
- package/resource/utils/index.js +20 -0
- package/resource/utils/relation-util.d.ts +84 -0
- package/resource/utils/relation-util.js +344 -0
- package/resource/utils/sort-util.d.ts +20 -0
- package/resource/utils/sort-util.js +218 -0
- package/security/create-auth-resources.js +2 -2
- package/security/helper.js +1 -1
- package/security/resources/api-key/model.js +1 -0
- package/security/resources/api-key/service.d.ts +1 -1
- package/security/resources/role/model.js +1 -1
- package/server/create-server.js +4 -1
- package/server/register-route.js +1 -0
- package/server/swagger.js +87 -21
- package/server/virtual-projection.js +10 -8
- package/types/generated.d.ts +110 -4
- package/utils/schema-util.js +1 -1
- package/utils/virtual-util.js +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mapRelationInclusions = mapRelationInclusions;
|
|
4
|
+
exports.mapRelationActions = mapRelationActions;
|
|
5
|
+
exports.relationWriteData = relationWriteData;
|
|
6
|
+
exports.missingRelationFields = missingRelationFields;
|
|
7
|
+
exports.createdByConnect = createdByConnect;
|
|
8
|
+
const common_1 = require("@appweaver/common");
|
|
9
|
+
const context_1 = require("../../context");
|
|
10
|
+
const security_1 = require("../../security");
|
|
11
|
+
const errors_1 = require("../../errors");
|
|
12
|
+
/**
|
|
13
|
+
* Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
|
|
14
|
+
* its configured output type allows it for the given action, or always when no action is specified, and the relations
|
|
15
|
+
* configured with an output count contribute to the `_count` selection instead.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} resourceName - The name of the model whose relations are included.
|
|
18
|
+
* @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
|
|
19
|
+
* of every field. When omitted, all fields whose output type is not `none` are included.
|
|
20
|
+
* @return {Object} The `include` clause mapping each included field to `true` or to its own nested `include` clause,
|
|
21
|
+
* extended with a `_count` selection for the relations configured with an output count.
|
|
22
|
+
*/
|
|
23
|
+
function mapRelationInclusions(resourceName, action) {
|
|
24
|
+
const inclusion = {};
|
|
25
|
+
const resourceModel = (0, context_1.injectModel)(resourceName);
|
|
26
|
+
const relationConfig = resourceModel.config.relations;
|
|
27
|
+
const fileConfig = resourceModel.config.files;
|
|
28
|
+
const relationModelProps = (0, common_1.extractSchemaProperties)(resourceModel.relationsModel);
|
|
29
|
+
const fileModelProps = (0, common_1.extractSchemaProperties)(resourceModel.filesModel);
|
|
30
|
+
// Add relation and file fields to the inclusion map if the include type is
|
|
31
|
+
// satisfied or the requested action is not specified. Also, add the count
|
|
32
|
+
// aggregation actions for relations if configured.
|
|
33
|
+
for (const key of Object.keys({
|
|
34
|
+
...relationModelProps,
|
|
35
|
+
...fileModelProps
|
|
36
|
+
})) {
|
|
37
|
+
const relationField = relationConfig?.[key] || fileConfig?.[key];
|
|
38
|
+
if (relationField?.output?.count) {
|
|
39
|
+
inclusion._count = inclusion._count ?? { select: {} };
|
|
40
|
+
inclusion._count.select[key] = true;
|
|
41
|
+
}
|
|
42
|
+
// Check if the relation should be included based on the output type
|
|
43
|
+
if (shouldIncludeRelation(relationField?.output?.type, action)) {
|
|
44
|
+
inclusion[key] = buildNestedInclusion(relationField, action);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return inclusion;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Maps the relation and file fields of a write payload to the Prisma nested write actions. Bare key values and arrays
|
|
51
|
+
* of them are normalized to objects first, then every item is classified: items with an id and additional data become
|
|
52
|
+
* inline updates when the parent action is an update and inline updates are enabled for the relation, items with only
|
|
53
|
+
* an id are connected, and items without an id are created inline or matched through a connect-or-create when
|
|
54
|
+
* `input.uniqueKey` is configured. On an update action, the items of the current record that are absent from the new
|
|
55
|
+
* value, as well as the relations set to null, are disconnected, or deleted when `orphanRemoval` is configured.
|
|
56
|
+
* Relations that were not loaded on the current record are left untouched.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} resourceName - The name of the model the write payload belongs to.
|
|
59
|
+
* @param {'create'|'update'} action - The write action the relation actions are mapped for. Inline updates and the
|
|
60
|
+
* removal of the relations absent from the new value are only applied on an update action.
|
|
61
|
+
* @param {Object} data - The sanitized write payload whose relation and file fields are mapped. The non-relation
|
|
62
|
+
* fields are skipped, except for the null values of the array scalar fields, which are mapped to an empty array on an
|
|
63
|
+
* update action and to undefined on a create action.
|
|
64
|
+
* @param {Object} [currentData] - The currently stored record with its relations loaded, required on an update action
|
|
65
|
+
* to determine which relations to disconnect or delete.
|
|
66
|
+
* @return {RelationActions} The nested write clause per relation field, mapping each one to its `connect`, `create`,
|
|
67
|
+
* `update`, `connectOrCreate`, `disconnect` and `delete` actions, or to undefined for the relations no action can be
|
|
68
|
+
* applied to.
|
|
69
|
+
* @throws {HttpError} 400 if an inline create payload is missing required fields, or if the relation accepts no new
|
|
70
|
+
* records and an id was not provided.
|
|
71
|
+
*/
|
|
72
|
+
function mapRelationActions(resourceName, action, data, currentData) {
|
|
73
|
+
const relations = {};
|
|
74
|
+
const resourceModel = (0, context_1.injectModel)(resourceName);
|
|
75
|
+
const readModel = resourceModel.readModel;
|
|
76
|
+
const relationsModel = resourceModel.relationsModel;
|
|
77
|
+
const relationsConfig = resourceModel.config.relations;
|
|
78
|
+
for (const key in data) {
|
|
79
|
+
let value = data[key];
|
|
80
|
+
const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
|
|
81
|
+
if (!relationSchema) {
|
|
82
|
+
// Set empty array or undefined value for null array type fields
|
|
83
|
+
if (value === null) {
|
|
84
|
+
if ((0, common_1.extractSchemaProperties)(readModel, key)?.type === 'array') {
|
|
85
|
+
relations[key] = action === 'update' ? [] : undefined;
|
|
86
|
+
}
|
|
87
|
+
else if (action === 'create') {
|
|
88
|
+
relations[key] = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Skip mapping for non-relation fields
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const config = relationsConfig?.[key];
|
|
95
|
+
// The unique key is only used to match existing records for the
|
|
96
|
+
// connect-or-create action; connect and inline update always use the id
|
|
97
|
+
const uniqueKey = config?.input?.allowCreate
|
|
98
|
+
? config?.input?.uniqueKey || 'id'
|
|
99
|
+
: 'id';
|
|
100
|
+
const isArrayType = relationSchema.type === 'array';
|
|
101
|
+
// Normalize array values to single values if a value type is not an array
|
|
102
|
+
if (!isArrayType && (0, common_1.isArray)(value)) {
|
|
103
|
+
value = value[0];
|
|
104
|
+
}
|
|
105
|
+
// Set null values to undefined value for create actions. On update action
|
|
106
|
+
// they will be returned as disconnected relations.
|
|
107
|
+
if (action === 'create' && value === null) {
|
|
108
|
+
relations[key] = undefined;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Normalize plain key values or arrays to object values
|
|
112
|
+
if (isArrayType) {
|
|
113
|
+
if ((0, common_1.isArray)(value) && !(0, common_1.isPlainObject)(value[0])) {
|
|
114
|
+
value = value.map((v) => ({
|
|
115
|
+
[uniqueKey]: v
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (!(0, common_1.isObject)(value)) {
|
|
120
|
+
// Only bare key values are wrapped. The loose object check keeps
|
|
121
|
+
// null values untouched, so they still map to a disconnect action.
|
|
122
|
+
value = { [uniqueKey]: value };
|
|
123
|
+
}
|
|
124
|
+
// Classify every relation input item into its relation write action:
|
|
125
|
+
// items with an id and additional data become inline updates (on parent
|
|
126
|
+
// update requests with inline updates enabled), items with only an id are
|
|
127
|
+
// connected, and items without an id are created inline or matched with
|
|
128
|
+
// connect-or-create, both of which require `allowCreate`
|
|
129
|
+
if (value) {
|
|
130
|
+
const items = isArrayType && (0, common_1.isArray)(value) ? value : [value];
|
|
131
|
+
const createdBy = createdByConnect(config?.model ?? resourceName);
|
|
132
|
+
const actions = {
|
|
133
|
+
connect: [],
|
|
134
|
+
update: [],
|
|
135
|
+
create: [],
|
|
136
|
+
connectOrCreate: []
|
|
137
|
+
};
|
|
138
|
+
for (const item of items) {
|
|
139
|
+
if (!(0, common_1.isPlainObject)(item)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (item.id !== undefined && item.id !== null) {
|
|
143
|
+
const { id, ...itemData } = item;
|
|
144
|
+
const updateData = relationWriteData(config?.model, 'update', itemData);
|
|
145
|
+
if (action === 'update' &&
|
|
146
|
+
config?.input?.allowUpdate &&
|
|
147
|
+
Object.keys(updateData).length > 0) {
|
|
148
|
+
actions.update.push({ where: { id }, data: updateData });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
actions.connect.push({ id });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (!config?.input?.allowCreate) {
|
|
155
|
+
// Without inline creation the related record must already exist,
|
|
156
|
+
// so it is created through its own endpoint first
|
|
157
|
+
throw new errors_1.HttpError(`${resourceName} relation '${key}' does not accept new records, an id is required`, 400);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const createData = relationWriteData(config?.model, 'create', item);
|
|
161
|
+
const missingFields = missingRelationFields(config?.model, createData);
|
|
162
|
+
if (missingFields.length > 0) {
|
|
163
|
+
throw new errors_1.HttpError(`${resourceName} relation '${key}' is missing required fields: ${missingFields.join(', ')}`, 400);
|
|
164
|
+
}
|
|
165
|
+
// A unique key matches an existing record before a new one is created
|
|
166
|
+
if (config?.input?.uniqueKey) {
|
|
167
|
+
actions.connectOrCreate.push({
|
|
168
|
+
where: { [uniqueKey]: item[uniqueKey] },
|
|
169
|
+
create: { ...createData, createdBy }
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
actions.create.push({ ...createData, createdBy });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const relationActions = {};
|
|
178
|
+
for (const [actionName, actionItems] of Object.entries(actions)) {
|
|
179
|
+
if (actionItems.length > 0) {
|
|
180
|
+
relationActions[actionName] = isArrayType
|
|
181
|
+
? actionItems
|
|
182
|
+
: actionItems[0];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(relationActions).length > 0) {
|
|
186
|
+
relations[key] = relationActions;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Map relation disconnects if keys are no longer present or the new
|
|
190
|
+
// value is null. Delete relations if orphanRemoval is set to true.
|
|
191
|
+
// Relations that are not set on the current resource are left untouched,
|
|
192
|
+
// so no relation action is applied for them.
|
|
193
|
+
if (action === 'update') {
|
|
194
|
+
const currentValue = currentData[key];
|
|
195
|
+
const removalMethod = config?.orphanRemoval ? 'delete' : 'disconnect';
|
|
196
|
+
if (currentValue && isArrayType) {
|
|
197
|
+
const newValueKeys = value?.map((v) => v[uniqueKey]) ?? [];
|
|
198
|
+
const currentValueKeys = currentValue
|
|
199
|
+
.filter((v) => newValueKeys.indexOf(v[uniqueKey]) === -1)
|
|
200
|
+
.map((v) => ({
|
|
201
|
+
[uniqueKey]: v[uniqueKey]
|
|
202
|
+
}));
|
|
203
|
+
if (currentValueKeys.length > 0) {
|
|
204
|
+
relations[key] = {
|
|
205
|
+
[removalMethod]: currentValueKeys,
|
|
206
|
+
...(relations[key] ?? {})
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (currentValue && value === null) {
|
|
211
|
+
relations[key] = {
|
|
212
|
+
[removalMethod]: { [uniqueKey]: currentValue[uniqueKey] },
|
|
213
|
+
...(relations[key] ?? {})
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
else if (currentValue === undefined) {
|
|
217
|
+
// The relation was not loaded on the current record, so no relation
|
|
218
|
+
// action can be applied safely
|
|
219
|
+
relations[key] = undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return relations;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Restricts an inline relation payload to the fields the related model accepts for the given action. The request
|
|
227
|
+
* schema accepts the create and the update fields together, since the properties it does not declare are stripped
|
|
228
|
+
* before the request reaches the service, so the configured field restrictions of the related model are applied here
|
|
229
|
+
* instead.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} [resourceName] - The name of the related model whose field restrictions are applied. When omitted,
|
|
232
|
+
* the payload is returned unchanged.
|
|
233
|
+
* @param {'create'|'update'} action - The write action the payload is restricted for, selecting either the relation
|
|
234
|
+
* create or the relation update model of the related resource.
|
|
235
|
+
* @param {Object} data - The inline relation payload to restrict.
|
|
236
|
+
* @return {Object} The payload reduced to the fields the related model accepts for the action, excluding the `id`
|
|
237
|
+
* field, or the payload unchanged if the related model declares no fields for it.
|
|
238
|
+
*/
|
|
239
|
+
function relationWriteData(resourceName, action, data) {
|
|
240
|
+
const resourceModel = resourceName
|
|
241
|
+
? (0, context_1.injectModel)(resourceName, false)
|
|
242
|
+
: undefined;
|
|
243
|
+
const writeModel = action === 'create'
|
|
244
|
+
? resourceModel?.relationCreateModel
|
|
245
|
+
: resourceModel?.relationUpdateModel;
|
|
246
|
+
const properties = (0, common_1.extractSchemaProperties)(writeModel);
|
|
247
|
+
if (!properties) {
|
|
248
|
+
return data;
|
|
249
|
+
}
|
|
250
|
+
return (0, common_1.pickProperties)(data, Object.keys(properties).filter((name) => name !== 'id'));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Lists the fields the related model requires on creation that the given inline relation payload does not provide.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} [resourceName] - The name of the related model whose required fields are checked. When omitted, no
|
|
256
|
+
* fields are reported as missing.
|
|
257
|
+
* @param {Object} data - The inline relation payload to check.
|
|
258
|
+
* @return {string[]} The names of the fields the related model requires on creation that the payload leaves
|
|
259
|
+
* undefined, or an empty list if the related model declares no create schema.
|
|
260
|
+
*/
|
|
261
|
+
function missingRelationFields(resourceName, data) {
|
|
262
|
+
const resourceModel = resourceName
|
|
263
|
+
? (0, context_1.injectModel)(resourceName, false)
|
|
264
|
+
: undefined;
|
|
265
|
+
const createModel = resourceModel?.relationCreateModel;
|
|
266
|
+
if (!createModel) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
const schema = createModel['$ref']
|
|
270
|
+
? createModel['$defs']?.[createModel['$ref']]
|
|
271
|
+
: createModel;
|
|
272
|
+
return (schema?.required ?? []).filter((name) => data[name] === undefined);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Builds the connect action for the `createdBy` audit relation of a resource, pointing at the currently authenticated
|
|
276
|
+
* user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} resourceName - The name of the model the audit relation is built for.
|
|
279
|
+
* @return {{connect: {id: number}}|undefined} The connect action pointing at the id of the currently authenticated
|
|
280
|
+
* user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
|
|
281
|
+
*/
|
|
282
|
+
function createdByConnect(resourceName) {
|
|
283
|
+
const resourceModel = (0, context_1.injectModel)(resourceName, false);
|
|
284
|
+
if (resourceModel?.config.audit?.createdById === false) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const currentUser = (0, security_1.currentAuthUser)();
|
|
288
|
+
return currentUser
|
|
289
|
+
? {
|
|
290
|
+
connect: {
|
|
291
|
+
id: currentUser.id
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
: undefined;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Resolves the inclusion value of a single relation field by walking its configured nested output includes
|
|
298
|
+
* recursively. Returns `true` when the relation has no nested includes to apply for the given action, or a nested
|
|
299
|
+
* `include` clause otherwise.
|
|
300
|
+
*
|
|
301
|
+
* @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
|
|
302
|
+
* from its `output.include` property.
|
|
303
|
+
* @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
|
|
304
|
+
* of every nested relation.
|
|
305
|
+
* @return {boolean|Object} True if the relation has no nested relations to include for the given action, or the
|
|
306
|
+
* nested `include` clause otherwise.
|
|
307
|
+
*/
|
|
308
|
+
function buildNestedInclusion(relationField, action) {
|
|
309
|
+
const nestedIncludeConfig = relationField?.output?.include;
|
|
310
|
+
if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
const nestedInclusion = {};
|
|
314
|
+
for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig)) {
|
|
315
|
+
// Check if the nested relation should be included
|
|
316
|
+
if (shouldIncludeRelation(nestedOutput?.type, action)) {
|
|
317
|
+
// Recursively build nested inclusions
|
|
318
|
+
nestedInclusion[nestedKey] = buildNestedInclusion({ output: nestedOutput }, action);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return Object.keys(nestedInclusion).length > 0
|
|
322
|
+
? { include: nestedInclusion }
|
|
323
|
+
: true;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Decides whether a relation with the given configured output type is included for the given action. The `none` type
|
|
327
|
+
* is never included, the `single` type is excluded from the query action, and the `multiple` type is only included on
|
|
328
|
+
* the query action or when no action is specified.
|
|
329
|
+
*
|
|
330
|
+
* @param {OutputType} [outputType] - The configured output type of the relation. When omitted, the relation is
|
|
331
|
+
* included for every action.
|
|
332
|
+
* @param {ActionType} [action] - The action the relation would be included for. When omitted, every output type
|
|
333
|
+
* except `none` is included.
|
|
334
|
+
* @return {boolean} True if the relation should be included, false otherwise.
|
|
335
|
+
*/
|
|
336
|
+
function shouldIncludeRelation(outputType, action) {
|
|
337
|
+
if (outputType === 'none') {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (outputType === 'single' && action === 'query') {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return !(outputType === 'multiple' && action && action !== 'query');
|
|
344
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ActionType, QuerySort } from '@appweaver/common';
|
|
2
|
+
/**
|
|
3
|
+
* Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
|
|
4
|
+
* field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
|
|
5
|
+
* and `desc` field directions. Both forms support the same field paths: a scalar field of the
|
|
6
|
+
* model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
|
|
7
|
+
* record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
|
|
8
|
+
* (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
|
|
9
|
+
*
|
|
10
|
+
* @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
|
|
11
|
+
* @param {string} resourceName - The name of the model the sort is applied on.
|
|
12
|
+
* @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
|
|
13
|
+
* response and are therefore available to sort by. Defaults to the query action.
|
|
14
|
+
* @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
|
|
15
|
+
* of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
|
|
16
|
+
* entry accepts a single field path.
|
|
17
|
+
* @throws {HttpError} 400 if a field does not exist on its model, cannot be sorted by, is given an unknown sort
|
|
18
|
+
* direction, or targets a relation that the action does not include in its response.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mapSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mapSortValues = mapSortValues;
|
|
4
|
+
const common_1 = require("@appweaver/common");
|
|
5
|
+
const context_1 = require("../../context");
|
|
6
|
+
const errors_1 = require("../../errors");
|
|
7
|
+
const relation_util_1 = require("./relation-util");
|
|
8
|
+
/** The audit fields every model declares unless it opts out of them. */
|
|
9
|
+
const defaultAuditFields = {
|
|
10
|
+
updatedAt: true,
|
|
11
|
+
createdAt: true,
|
|
12
|
+
createdById: true
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
|
|
16
|
+
* field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
|
|
17
|
+
* and `desc` field directions. Both forms support the same field paths: a scalar field of the
|
|
18
|
+
* model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
|
|
19
|
+
* record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
|
|
20
|
+
* (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
|
|
21
|
+
*
|
|
22
|
+
* @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
|
|
23
|
+
* @param {string} resourceName - The name of the model the sort is applied on.
|
|
24
|
+
* @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
|
|
25
|
+
* response and are therefore available to sort by. Defaults to the query action.
|
|
26
|
+
* @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
|
|
27
|
+
* of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
|
|
28
|
+
* entry accepts a single field path.
|
|
29
|
+
* @throws {HttpError} 400 if a field does not exist on its model, cannot be sorted by, is given an unknown sort
|
|
30
|
+
* direction, or targets a relation that the action does not include in its response.
|
|
31
|
+
*/
|
|
32
|
+
function mapSortValues(sort, resourceName, action = 'query') {
|
|
33
|
+
const inclusions = (0, relation_util_1.mapRelationInclusions)(resourceName, action);
|
|
34
|
+
const orderBy = [];
|
|
35
|
+
const mappedPaths = new Set();
|
|
36
|
+
for (const [path, direction] of sortEntries(sort)) {
|
|
37
|
+
const mappedPath = mapSortPath(resourceName, path, inclusions);
|
|
38
|
+
// Every field gets an entry of its own, since a database order entry holds
|
|
39
|
+
// a single field path, even when several of them share a relation. A field
|
|
40
|
+
// listed twice is already ordered by its first entry, so it is dropped
|
|
41
|
+
if (!mappedPath || mappedPaths.has(mappedPath)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
mappedPaths.add(mappedPath);
|
|
45
|
+
orderBy.push((0, common_1.setValue)({}, mappedPath, direction));
|
|
46
|
+
}
|
|
47
|
+
return orderBy;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Flattens a sort input into the list of its field paths and directions, keeping the order the fields were declared
|
|
51
|
+
* in. String inputs are split on commas, with a `-` or `+` prefix selecting the direction, and object inputs are
|
|
52
|
+
* walked recursively, joining the nested keys with a dot.
|
|
53
|
+
*
|
|
54
|
+
* @param {QuerySort} sort - The sort input to flatten.
|
|
55
|
+
* @param {string} [parentPath] - The dot notation path of the object the walk descended from.
|
|
56
|
+
* @return {SortEntry[]} The flattened sort entries, each holding a dot notation field path and its direction.
|
|
57
|
+
* @throws {HttpError} 400 if a sort value is neither a direction nor a nested object of directions.
|
|
58
|
+
*/
|
|
59
|
+
function sortEntries(sort, parentPath = '') {
|
|
60
|
+
if ((0, common_1.isString)(sort)) {
|
|
61
|
+
return sort
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((part) => part.trim())
|
|
64
|
+
.filter((part) => part.length > 0 && part !== '-' && part !== '+')
|
|
65
|
+
.map((part) => [
|
|
66
|
+
part.replace(/^[-+]/, ''),
|
|
67
|
+
part.startsWith('-') ? 'desc' : 'asc'
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
if (!(0, common_1.isPlainObject)(sort)) {
|
|
71
|
+
throw new errors_1.HttpError(`Invalid sort value${parentPath ? ` for the '${parentPath}' field` : ''}, ` +
|
|
72
|
+
'expected a field list string or a sort object', 400);
|
|
73
|
+
}
|
|
74
|
+
const entries = [];
|
|
75
|
+
for (const [field, value] of Object.entries(sort)) {
|
|
76
|
+
if (value === undefined || value === null) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const path = parentPath ? `${parentPath}.${field}` : field;
|
|
80
|
+
if ((0, common_1.isPlainObject)(value)) {
|
|
81
|
+
entries.push(...sortEntries(value, path));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (value !== 'asc' && value !== 'desc') {
|
|
85
|
+
throw new errors_1.HttpError(`Invalid sort direction '${value}' for the '${path}' field, expected 'asc' or 'desc'`, 400);
|
|
86
|
+
}
|
|
87
|
+
entries.push([path, value]);
|
|
88
|
+
}
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolves a single sort field path against the model it is applied on, validating every segment and rewriting the
|
|
93
|
+
* relation counts to their `_count` form. Relation segments are additionally checked against the relations the action
|
|
94
|
+
* includes in its response, since a relation that is not included cannot be sorted by.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} resourceName - The name of the model the path starts at.
|
|
97
|
+
* @param {string} path - The dot notation field path to resolve.
|
|
98
|
+
* @param {Object} inclusions - The `include` clause of the action, as built by {@link mapRelationInclusions}.
|
|
99
|
+
* @return {string|undefined} The resolved dot notation path, or undefined when the field is the `createdAt` audit
|
|
100
|
+
* field of a model that does not audit it, in which case the sort entry is dropped.
|
|
101
|
+
* @throws {HttpError} 400 if a segment does not exist on its model, cannot be sorted by, or targets a relation the
|
|
102
|
+
* action does not include in its response.
|
|
103
|
+
*/
|
|
104
|
+
function mapSortPath(resourceName, path, inclusions) {
|
|
105
|
+
const segments = path.split('.').filter((segment) => segment.length > 0);
|
|
106
|
+
const mappedSegments = [];
|
|
107
|
+
let modelName = resourceName;
|
|
108
|
+
let modelInclusions = inclusions;
|
|
109
|
+
for (const [index, field] of segments.entries()) {
|
|
110
|
+
const model = (0, context_1.injectModel)(modelName, false);
|
|
111
|
+
if (!model) {
|
|
112
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${modelName}' model is not loaded`, 400);
|
|
113
|
+
}
|
|
114
|
+
const isLast = index === segments.length - 1;
|
|
115
|
+
const relation = relationField(model, field);
|
|
116
|
+
// A scalar field ends the path, so it is the only segment a relation of the
|
|
117
|
+
// same name cannot shadow
|
|
118
|
+
if (!relation && isScalarField(model, field)) {
|
|
119
|
+
if (!isLast) {
|
|
120
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, '${field}' is not a relation of the ${model.name} model`, 400);
|
|
121
|
+
}
|
|
122
|
+
// The default sort still names the createdAt field on the models that do
|
|
123
|
+
// not audit it, so the entry is dropped instead of rejected
|
|
124
|
+
if (field === 'createdAt' && model.config.audit?.createdAt === false) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
mappedSegments.push(field);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
// A count field sorts by the number of related records, which the database
|
|
131
|
+
// orders by through the _count aggregation of the relation
|
|
132
|
+
if (!relation && isLast && (0, common_1.isCountField)(field)) {
|
|
133
|
+
const countedField = field.slice(0, -'Count'.length);
|
|
134
|
+
const countedRelation = relationField(model, countedField);
|
|
135
|
+
if (countedRelation && isFieldArray(countedRelation)) {
|
|
136
|
+
mappedSegments.push(countedField, '_count');
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!relation) {
|
|
141
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, '${field}' is not a sortable field of the ${model.name} model`, 400);
|
|
142
|
+
}
|
|
143
|
+
// A to-many relation holds no single value to sort by, so it is sorted by
|
|
144
|
+
// the number of its related records instead
|
|
145
|
+
if (isFieldArray(relation)) {
|
|
146
|
+
if (!isLast) {
|
|
147
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation holds a list of records, ` +
|
|
148
|
+
`sort by the '${(0, common_1.countFieldName)(field)}' field instead`, 400);
|
|
149
|
+
}
|
|
150
|
+
mappedSegments.push(field, '_count');
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
if (isLast) {
|
|
154
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation requires a nested field to sort by`, 400);
|
|
155
|
+
}
|
|
156
|
+
const inclusion = modelInclusions?.[field];
|
|
157
|
+
if (!inclusion) {
|
|
158
|
+
throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation is not included in the response`, 400);
|
|
159
|
+
}
|
|
160
|
+
mappedSegments.push(field);
|
|
161
|
+
modelName = (0, common_1.capitalize)(relatedModelName(model, field));
|
|
162
|
+
modelInclusions = (0, common_1.isPlainObject)(inclusion) ? inclusion.include : undefined;
|
|
163
|
+
}
|
|
164
|
+
return mappedSegments.join('.');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reads the relation or file field configuration of a model field.
|
|
168
|
+
*
|
|
169
|
+
* @param {ResourceModel} model - The model the field belongs to.
|
|
170
|
+
* @param {string} field - The name of the field to read.
|
|
171
|
+
* @return {RelationField|FileField|undefined} The relation or file configuration of the field, or undefined when the
|
|
172
|
+
* field is not a relation.
|
|
173
|
+
*/
|
|
174
|
+
function relationField(model, field) {
|
|
175
|
+
return model.config.relations?.[field] ?? model.config.files?.[field];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolves the model name a relation or file field of a model points at.
|
|
179
|
+
*
|
|
180
|
+
* @param {ResourceModel} model - The model the relation belongs to.
|
|
181
|
+
* @param {string} field - The name of the relation field.
|
|
182
|
+
* @return {string} The name of the related model, which is always `File` for a file field.
|
|
183
|
+
*/
|
|
184
|
+
function relatedModelName(model, field) {
|
|
185
|
+
return model.config.relations?.[field]?.model ?? 'File';
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Determines whether a relation or file field holds a list of related records, which can only be sorted by their
|
|
189
|
+
* count.
|
|
190
|
+
*
|
|
191
|
+
* @param {RelationField|FileField} field - The relation or file field configuration.
|
|
192
|
+
* @return {boolean} True when the field holds a list of related records.
|
|
193
|
+
*/
|
|
194
|
+
function isFieldArray(field) {
|
|
195
|
+
return 'model' in field ? (0, common_1.isRelationArray)(field) : field.array === true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Determines whether a model field is a scalar column the database can sort by. The id and the audited fields are
|
|
199
|
+
* always sortable, the configured scalars are sortable unless they are hidden or hold a list of values, and the
|
|
200
|
+
* virtual fields are never sortable, since they have no column of their own.
|
|
201
|
+
*
|
|
202
|
+
* @param {ResourceModel} model - The model the field belongs to.
|
|
203
|
+
* @param {string} field - The name of the field to check.
|
|
204
|
+
* @return {boolean} True when the field can be sorted by.
|
|
205
|
+
*/
|
|
206
|
+
function isScalarField(model, field) {
|
|
207
|
+
if (field === 'id') {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
const auditFields = { ...defaultAuditFields, ...(model.config.audit ?? {}) };
|
|
211
|
+
if (field in auditFields) {
|
|
212
|
+
// The createdAt field of a model that does not audit it is still accepted,
|
|
213
|
+
// so the default sort value does not have to be adjusted per model
|
|
214
|
+
return auditFields[field] === true || field === 'createdAt';
|
|
215
|
+
}
|
|
216
|
+
const scalar = model.config.scalars?.[field];
|
|
217
|
+
return !!scalar && !scalar.hidden && scalar.array !== true;
|
|
218
|
+
}
|
|
@@ -58,7 +58,7 @@ function createAuthModel(config) {
|
|
|
58
58
|
const authModelRelations = {
|
|
59
59
|
roles: {
|
|
60
60
|
model: 'Role',
|
|
61
|
-
|
|
61
|
+
type: 'manyToMany',
|
|
62
62
|
input: {
|
|
63
63
|
type: 'all'
|
|
64
64
|
},
|
|
@@ -75,8 +75,8 @@ function createAuthModel(config) {
|
|
|
75
75
|
? {
|
|
76
76
|
apiKeys: {
|
|
77
77
|
model: 'ApiKey',
|
|
78
|
+
type: 'oneToMany',
|
|
78
79
|
mappedBy: (0, common_1.uncapitalize)(config.name),
|
|
79
|
-
array: true,
|
|
80
80
|
input: {
|
|
81
81
|
type: 'none'
|
|
82
82
|
},
|
package/security/helper.js
CHANGED
|
@@ -244,7 +244,7 @@ function checkScopeAccess(url, authScope) {
|
|
|
244
244
|
const securityPrefix = common_1.config.SECURITY_ROUTE_PREFIX.replace(/\/$/, '');
|
|
245
245
|
const accountPrefix = common_1.config.SECURITY_ACCOUNT_ROUTE_PREFIX.replace(/\/$/, '');
|
|
246
246
|
const refreshPath = `${securityPrefix}/refresh`;
|
|
247
|
-
const twoFASendPath = `${accountPrefix}/2fa-
|
|
247
|
+
const twoFASendPath = `${accountPrefix}/send-2fa-code`;
|
|
248
248
|
const twoFAVerifyPath = `${accountPrefix}/verify-2fa-code`;
|
|
249
249
|
const scopeConfigs = [
|
|
250
250
|
{
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { ApiKey } from '../../../types';
|
|
2
|
-
declare const _default: import("@appweaver/common").Ctor<import("../../..").ResourceService<ApiKey, ApiKey, ApiKey, any,
|
|
2
|
+
declare const _default: import("@appweaver/common").Ctor<import("../../..").ResourceService<ApiKey, ApiKey, ApiKey, any, import("@appweaver/common").QueryFilter<ApiKey>>> | undefined;
|
|
3
3
|
export default _default;
|
package/server/create-server.js
CHANGED
|
@@ -45,7 +45,10 @@ function createServer() {
|
|
|
45
45
|
const server = (0, fastify_1.default)({
|
|
46
46
|
ajv: {
|
|
47
47
|
customOptions: {
|
|
48
|
-
removeAdditional: 'all'
|
|
48
|
+
removeAdditional: 'all',
|
|
49
|
+
// Query filter schemas declare plain values as a list of accepted
|
|
50
|
+
// primitive types, so the validator does not coerce them
|
|
51
|
+
allowUnionTypes: true
|
|
49
52
|
},
|
|
50
53
|
plugins: [(ajv) => ajv.addKeyword('example')]
|
|
51
54
|
},
|
package/server/register-route.js
CHANGED