@terreno/api 0.15.1 → 0.16.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/CHANGELOG.md +21 -0
- package/dist/actions.d.ts +55 -0
- package/dist/actions.js +472 -0
- package/dist/actions.openApi.test.d.ts +1 -0
- package/dist/actions.openApi.test.js +252 -0
- package/dist/actions.test.d.ts +1 -0
- package/dist/actions.test.js +946 -0
- package/dist/api.d.ts +5 -0
- package/dist/api.js +4 -1
- package/dist/consentApp.js +118 -102
- package/dist/docLoader.d.ts +7 -0
- package/dist/docLoader.js +154 -0
- package/dist/docLoader.test.d.ts +1 -0
- package/dist/docLoader.test.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/permissions.d.ts +2 -2
- package/dist/permissions.js +11 -107
- package/dist/zodOpenApi.d.ts +2 -0
- package/dist/zodOpenApi.js +7 -0
- package/package.json +6 -3
- package/src/actions.openApi.test.ts +176 -0
- package/src/actions.test.ts +636 -0
- package/src/actions.ts +441 -0
- package/src/api.ts +14 -1
- package/src/consentApp.ts +80 -81
- package/src/docLoader.test.ts +58 -0
- package/src/docLoader.ts +77 -0
- package/src/index.ts +2 -0
- package/src/permissions.ts +4 -62
- package/src/zodOpenApi.ts +6 -0
package/src/actions.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import {OpenAPIRegistry, OpenApiGeneratorV3} from "@asteasolutions/zod-to-openapi";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import type {NextFunction, Request, Response} from "express";
|
|
4
|
+
import type {Model} from "mongoose";
|
|
5
|
+
import type {ZodSchema, ZodType} from "zod";
|
|
6
|
+
import {asyncHandler, type ModelRouterOptions, type RESTMethod} from "./api";
|
|
7
|
+
import {authenticateMiddleware, type User} from "./auth";
|
|
8
|
+
import {loadDocOr404} from "./docLoader";
|
|
9
|
+
import {APIError} from "./errors";
|
|
10
|
+
import {defaultOpenApiErrorResponses} from "./openApi";
|
|
11
|
+
import {checkPermissions, type PermissionMethod} from "./permissions";
|
|
12
|
+
import {z} from "./zodOpenApi";
|
|
13
|
+
|
|
14
|
+
// At least two characters: leading letter plus one or more alphanumeric/_/- chars.
|
|
15
|
+
export const ACTION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]+$/;
|
|
16
|
+
|
|
17
|
+
export interface ActionContext<TDoc, TBody, TQuery> {
|
|
18
|
+
req: Request;
|
|
19
|
+
res: Response;
|
|
20
|
+
user: User | undefined;
|
|
21
|
+
body: TBody;
|
|
22
|
+
query: TQuery;
|
|
23
|
+
doc: TDoc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface BaseActionConfig<TBody, TQuery, TResponse> {
|
|
27
|
+
method: "GET" | "POST";
|
|
28
|
+
permissions: PermissionMethod<unknown>[];
|
|
29
|
+
body?: ZodSchema<TBody>;
|
|
30
|
+
query?: ZodSchema<TQuery>;
|
|
31
|
+
response?: ZodSchema<TResponse>;
|
|
32
|
+
summary?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
tag?: string;
|
|
35
|
+
status?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InstanceActionConfig<TDoc, TBody, TQuery, TResponse>
|
|
39
|
+
extends BaseActionConfig<TBody, TQuery, TResponse> {
|
|
40
|
+
handler: (ctx: ActionContext<TDoc, TBody, TQuery>) => TResponse | Promise<TResponse>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CollectionActionConfig<TBody, TQuery, TResponse>
|
|
44
|
+
extends BaseActionConfig<TBody, TQuery, TResponse> {
|
|
45
|
+
handler: (
|
|
46
|
+
ctx: Omit<ActionContext<never, TBody, TQuery>, "doc">
|
|
47
|
+
) => TResponse | Promise<TResponse>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const defineInstanceAction = <TDoc, TBody = unknown, TQuery = unknown, TResponse = unknown>(
|
|
51
|
+
config: InstanceActionConfig<TDoc, TBody, TQuery, TResponse>
|
|
52
|
+
): InstanceActionConfig<TDoc, TBody, TQuery, TResponse> => {
|
|
53
|
+
return config;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const defineCollectionAction = <TBody = unknown, TQuery = unknown, TResponse = unknown>(
|
|
57
|
+
config: CollectionActionConfig<TBody, TQuery, TResponse>
|
|
58
|
+
): CollectionActionConfig<TBody, TQuery, TResponse> => {
|
|
59
|
+
return config;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type ActionScope = "instance" | "collection";
|
|
63
|
+
|
|
64
|
+
type RegisteredAction<T> =
|
|
65
|
+
| {scope: "instance"; name: string; config: InstanceActionConfig<T, unknown, unknown, unknown>}
|
|
66
|
+
| {scope: "collection"; name: string; config: CollectionActionConfig<unknown, unknown, unknown>};
|
|
67
|
+
|
|
68
|
+
const mapActionToCrudMethod = (scope: ActionScope, httpMethod: "GET" | "POST"): RESTMethod => {
|
|
69
|
+
if (scope === "instance") {
|
|
70
|
+
return httpMethod === "GET" ? "read" : "update";
|
|
71
|
+
}
|
|
72
|
+
return httpMethod === "GET" ? "list" : "create";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const runActionPermissions = async <T>(
|
|
76
|
+
action: BaseActionConfig<unknown, unknown, unknown>,
|
|
77
|
+
scope: ActionScope,
|
|
78
|
+
model: Model<T>,
|
|
79
|
+
req: Request,
|
|
80
|
+
doc?: T
|
|
81
|
+
): Promise<void> => {
|
|
82
|
+
const method = mapActionToCrudMethod(scope, action.method);
|
|
83
|
+
const allowed = await checkPermissions(method, action.permissions, req.user, doc);
|
|
84
|
+
if (allowed) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!doc) {
|
|
89
|
+
throw new APIError({
|
|
90
|
+
status: 405,
|
|
91
|
+
title:
|
|
92
|
+
`Access to ${method.toUpperCase()} on ${model.modelName} ` + `denied for ${req.user?.id}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new APIError({
|
|
97
|
+
status: 403,
|
|
98
|
+
title:
|
|
99
|
+
`Access to ${method.toUpperCase()} on ${model.modelName}:${req.params.id} ` +
|
|
100
|
+
`denied for ${req.user?.id}`,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const flattenZodFieldErrors = (
|
|
105
|
+
fieldErrors: Record<string, string[] | undefined>
|
|
106
|
+
): Record<string, string> => {
|
|
107
|
+
const fields: Record<string, string> = {};
|
|
108
|
+
for (const [key, msgs] of Object.entries(fieldErrors)) {
|
|
109
|
+
if (msgs && msgs.length > 0) {
|
|
110
|
+
fields[key] = msgs[0];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return fields;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const validateActionRequest = <TBody, TQuery>({
|
|
117
|
+
action,
|
|
118
|
+
req,
|
|
119
|
+
}: {
|
|
120
|
+
action: BaseActionConfig<TBody, TQuery, unknown>;
|
|
121
|
+
req: Request;
|
|
122
|
+
}): {body: TBody | undefined; query: TQuery | undefined} => {
|
|
123
|
+
let body: TBody | undefined;
|
|
124
|
+
if (action.body) {
|
|
125
|
+
const parsedBody = action.body.safeParse(req.body);
|
|
126
|
+
if (!parsedBody.success) {
|
|
127
|
+
throw new APIError({
|
|
128
|
+
fields: flattenZodFieldErrors(parsedBody.error.flatten().fieldErrors),
|
|
129
|
+
status: 400,
|
|
130
|
+
title: "Validation failed",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
body = parsedBody.data;
|
|
134
|
+
} else {
|
|
135
|
+
body = req.body as TBody;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let query: TQuery | undefined;
|
|
139
|
+
if (action.query) {
|
|
140
|
+
const parsedQuery = action.query.safeParse(req.query);
|
|
141
|
+
if (!parsedQuery.success) {
|
|
142
|
+
throw new APIError({
|
|
143
|
+
fields: flattenZodFieldErrors(parsedQuery.error.flatten().fieldErrors),
|
|
144
|
+
status: 400,
|
|
145
|
+
title: "Validation failed",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
query = parsedQuery.data;
|
|
149
|
+
} else {
|
|
150
|
+
query = req.query as TQuery;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {body, query};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const wrapActionResponse = (
|
|
157
|
+
handlerResult: unknown,
|
|
158
|
+
action: BaseActionConfig<unknown, unknown, unknown>,
|
|
159
|
+
res: Response
|
|
160
|
+
): void => {
|
|
161
|
+
if (res.headersSent) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.status(action.status ?? 200).json({data: handlerResult ?? null});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
let inlineOpenApiSchemaCounter = 0;
|
|
168
|
+
|
|
169
|
+
const zodToJsonSchema = (zodSchema: ZodType): Record<string, unknown> => {
|
|
170
|
+
const registry = new OpenAPIRegistry();
|
|
171
|
+
const refId = `ActionInlineSchema${inlineOpenApiSchemaCounter++}`;
|
|
172
|
+
registry.register(refId, zodSchema);
|
|
173
|
+
const generator = new OpenApiGeneratorV3(registry.definitions);
|
|
174
|
+
const {components} = generator.generateComponents();
|
|
175
|
+
const schema = components?.schemas?.[refId];
|
|
176
|
+
if (schema && typeof schema === "object") {
|
|
177
|
+
return schema as Record<string, unknown>;
|
|
178
|
+
}
|
|
179
|
+
return {type: "object"};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const queryParametersFromSchema = (querySchema: ZodType): Record<string, unknown>[] => {
|
|
183
|
+
const jsonSchema = zodToJsonSchema(querySchema);
|
|
184
|
+
const properties = jsonSchema.properties as Record<string, Record<string, unknown>> | undefined;
|
|
185
|
+
if (!properties) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const requiredFields = (jsonSchema.required as string[] | undefined) ?? [];
|
|
189
|
+
return Object.entries(properties).map(([name, schema]) => ({
|
|
190
|
+
in: "query",
|
|
191
|
+
name,
|
|
192
|
+
required: requiredFields.includes(name),
|
|
193
|
+
schema,
|
|
194
|
+
}));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const createActionOpenApiMiddleware = <T>({
|
|
198
|
+
action,
|
|
199
|
+
scope,
|
|
200
|
+
actionName,
|
|
201
|
+
model,
|
|
202
|
+
options,
|
|
203
|
+
}: {
|
|
204
|
+
action: BaseActionConfig<unknown, unknown, unknown>;
|
|
205
|
+
scope: ActionScope;
|
|
206
|
+
actionName: string;
|
|
207
|
+
model: Model<T>;
|
|
208
|
+
options: Partial<ModelRouterOptions<T>>;
|
|
209
|
+
}): express.RequestHandler => {
|
|
210
|
+
if (!options.openApi?.path) {
|
|
211
|
+
return (_req, _res, next) => next();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const tag = action.tag ?? model.collection.collectionName;
|
|
215
|
+
const statusCode = String(action.status ?? 200);
|
|
216
|
+
|
|
217
|
+
const parameters: Record<string, unknown>[] = [];
|
|
218
|
+
if (scope === "instance") {
|
|
219
|
+
parameters.push({
|
|
220
|
+
in: "path",
|
|
221
|
+
name: "id",
|
|
222
|
+
required: true,
|
|
223
|
+
schema: {type: "string"},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (action.query) {
|
|
227
|
+
parameters.push(...queryParametersFromSchema(action.query));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const operation: Record<string, unknown> = {
|
|
231
|
+
description: action.description,
|
|
232
|
+
operationId: `${tag}_${actionName}`,
|
|
233
|
+
parameters,
|
|
234
|
+
responses: {
|
|
235
|
+
[statusCode]: {
|
|
236
|
+
content: {
|
|
237
|
+
"application/json": {
|
|
238
|
+
schema: action.response
|
|
239
|
+
? {
|
|
240
|
+
properties: {
|
|
241
|
+
data: zodToJsonSchema(action.response),
|
|
242
|
+
},
|
|
243
|
+
required: ["data"],
|
|
244
|
+
type: "object",
|
|
245
|
+
}
|
|
246
|
+
: {
|
|
247
|
+
properties: {
|
|
248
|
+
data: {type: "object"},
|
|
249
|
+
},
|
|
250
|
+
type: "object",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
description: "Successful response",
|
|
255
|
+
},
|
|
256
|
+
...defaultOpenApiErrorResponses,
|
|
257
|
+
},
|
|
258
|
+
summary: action.summary ?? `${actionName} ${scope} action`,
|
|
259
|
+
tags: [tag],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (action.body) {
|
|
263
|
+
operation.requestBody = {
|
|
264
|
+
content: {
|
|
265
|
+
"application/json": {
|
|
266
|
+
schema: zodToJsonSchema(action.body),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
required: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return options.openApi.path(operation);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const getArrayFieldNames = <T>(model: Model<T>): string[] => {
|
|
277
|
+
return Object.values(model.schema.paths)
|
|
278
|
+
.filter((config) => config.instance === "Array")
|
|
279
|
+
.map((config) => config.path);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Registration-time validation throws plain Error so misconfiguration fails at app boot.
|
|
283
|
+
const validateActionConfig = (
|
|
284
|
+
scope: ActionScope,
|
|
285
|
+
name: string,
|
|
286
|
+
config: BaseActionConfig<unknown, unknown, unknown>
|
|
287
|
+
): void => {
|
|
288
|
+
if (!name) {
|
|
289
|
+
throw new Error("Action name cannot be empty");
|
|
290
|
+
}
|
|
291
|
+
if (!ACTION_NAME_PATTERN.test(name)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Invalid action name "${name}". Action names must match ${ACTION_NAME_PATTERN.toString()}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (config.permissions === undefined) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Action "${name}" (${scope}) is missing required "permissions". ` +
|
|
299
|
+
"Provide at least one permission function, or [] to disable the action."
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (config.method !== "GET" && config.method !== "POST") {
|
|
303
|
+
throw new Error(`Action "${name}" (${scope}) only supports GET and POST methods`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export const assertNoActionCollisions = <T>(
|
|
308
|
+
model: Model<T>,
|
|
309
|
+
options: Pick<ModelRouterOptions<T>, "instanceActions" | "collectionActions">
|
|
310
|
+
): void => {
|
|
311
|
+
const arrayFields = new Set(getArrayFieldNames(model));
|
|
312
|
+
|
|
313
|
+
const validateMap = (
|
|
314
|
+
actions:
|
|
315
|
+
| Record<string, InstanceActionConfig<T, unknown, unknown, unknown>>
|
|
316
|
+
| Record<string, CollectionActionConfig<unknown, unknown, unknown>>
|
|
317
|
+
| undefined,
|
|
318
|
+
scope: ActionScope
|
|
319
|
+
): void => {
|
|
320
|
+
if (!actions) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const [name, config] of Object.entries(actions)) {
|
|
324
|
+
validateActionConfig(scope, name, config);
|
|
325
|
+
if (scope === "instance" && arrayFields.has(name)) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`instanceAction '${name}' collides with array field operations on /:id/${name}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
validateMap(options.instanceActions, "instance");
|
|
334
|
+
validateMap(options.collectionActions, "collection");
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const buildActionMiddleware = <T>(
|
|
338
|
+
model: Model<T>,
|
|
339
|
+
options: Partial<ModelRouterOptions<T>>,
|
|
340
|
+
registered: RegisteredAction<T>
|
|
341
|
+
): express.RequestHandler[] => {
|
|
342
|
+
const action = registered.config;
|
|
343
|
+
const scope = registered.scope;
|
|
344
|
+
const actionName = registered.name;
|
|
345
|
+
|
|
346
|
+
const preDocPermissions = async (req: Request, _res: Response, next: NextFunction) => {
|
|
347
|
+
try {
|
|
348
|
+
await runActionPermissions(action, scope, model, req);
|
|
349
|
+
return next();
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return next(error);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const loadDocAndPostPermissions =
|
|
356
|
+
scope === "instance"
|
|
357
|
+
? async (req: Request, _res: Response, next: NextFunction) => {
|
|
358
|
+
try {
|
|
359
|
+
const doc = await loadDocOr404<T>(
|
|
360
|
+
model,
|
|
361
|
+
req.params.id as string,
|
|
362
|
+
options.populatePaths
|
|
363
|
+
);
|
|
364
|
+
(req as Request & {obj?: T}).obj = doc;
|
|
365
|
+
await runActionPermissions(action, scope, model, req, doc);
|
|
366
|
+
return next();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return next(error);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
: null;
|
|
372
|
+
|
|
373
|
+
const validateAndRun = asyncHandler(async (req: Request, res: Response) => {
|
|
374
|
+
const {body, query} = validateActionRequest({action, req});
|
|
375
|
+
const doc = scope === "instance" ? (req as Request & {obj?: T}).obj : undefined;
|
|
376
|
+
|
|
377
|
+
const ctx = {
|
|
378
|
+
body,
|
|
379
|
+
doc,
|
|
380
|
+
query,
|
|
381
|
+
req,
|
|
382
|
+
res,
|
|
383
|
+
user: req.user,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const result =
|
|
387
|
+
scope === "instance"
|
|
388
|
+
? await (action as InstanceActionConfig<T, unknown, unknown, unknown>).handler(
|
|
389
|
+
ctx as ActionContext<T, unknown, unknown>
|
|
390
|
+
)
|
|
391
|
+
: await (action as CollectionActionConfig<unknown, unknown, unknown>).handler(
|
|
392
|
+
ctx as Omit<ActionContext<never, unknown, unknown>, "doc">
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
wrapActionResponse(result, action, res);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const chain: express.RequestHandler[] = [
|
|
399
|
+
authenticateMiddleware(options.allowAnonymous),
|
|
400
|
+
createActionOpenApiMiddleware({action, actionName, model, options, scope}),
|
|
401
|
+
preDocPermissions,
|
|
402
|
+
];
|
|
403
|
+
if (loadDocAndPostPermissions) {
|
|
404
|
+
chain.push(loadDocAndPostPermissions);
|
|
405
|
+
}
|
|
406
|
+
chain.push(validateAndRun);
|
|
407
|
+
return chain;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
export const registerActionRoutes = <T>(
|
|
411
|
+
router: express.Router,
|
|
412
|
+
model: Model<T>,
|
|
413
|
+
options: Partial<ModelRouterOptions<T>>
|
|
414
|
+
): void => {
|
|
415
|
+
const instanceActions = options.instanceActions ?? {};
|
|
416
|
+
const collectionActions = options.collectionActions ?? {};
|
|
417
|
+
|
|
418
|
+
const registeredActions: RegisteredAction<T>[] = [
|
|
419
|
+
...Object.entries(instanceActions).map(([name, config]) => ({
|
|
420
|
+
config,
|
|
421
|
+
name,
|
|
422
|
+
scope: "instance" as const,
|
|
423
|
+
})),
|
|
424
|
+
...Object.entries(collectionActions).map(([name, config]) => ({
|
|
425
|
+
config,
|
|
426
|
+
name,
|
|
427
|
+
scope: "collection" as const,
|
|
428
|
+
})),
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
for (const registered of registeredActions) {
|
|
432
|
+
const {config, name, scope} = registered;
|
|
433
|
+
const middleware = buildActionMiddleware(model, options, registered);
|
|
434
|
+
const routePath = scope === "instance" ? `/:id/${name}` : `/${name}`;
|
|
435
|
+
if (config.method === "GET") {
|
|
436
|
+
router.get(routePath, middleware);
|
|
437
|
+
} else {
|
|
438
|
+
router.post(routePath, middleware);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
};
|
package/src/api.ts
CHANGED
|
@@ -9,6 +9,12 @@ import cloneDeep from "lodash/cloneDeep";
|
|
|
9
9
|
import {DateTime} from "luxon";
|
|
10
10
|
import mongoose, {type Document, type Model} from "mongoose";
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
assertNoActionCollisions,
|
|
14
|
+
type CollectionActionConfig,
|
|
15
|
+
type InstanceActionConfig,
|
|
16
|
+
registerActionRoutes,
|
|
17
|
+
} from "./actions";
|
|
12
18
|
import {authenticateMiddleware, type User} from "./auth";
|
|
13
19
|
import {
|
|
14
20
|
APIError,
|
|
@@ -199,6 +205,10 @@ export interface ModelRouterOptions<T> {
|
|
|
199
205
|
maxLimit?: number; // defaults to 500
|
|
200
206
|
/** Custom route setup function. Receives the router and optionally the full options (including openApi). */
|
|
201
207
|
endpoints?: (router: express.Router, options?: Partial<ModelRouterOptions<T>>) => void;
|
|
208
|
+
/** Named instance-scoped operations at `/:id/:actionName` (GET or POST). */
|
|
209
|
+
instanceActions?: Record<string, InstanceActionConfig<T, unknown, unknown, unknown>>;
|
|
210
|
+
/** Named collection-scoped operations at `/:actionName` (GET or POST). */
|
|
211
|
+
collectionActions?: Record<string, CollectionActionConfig<unknown, unknown, unknown>>;
|
|
202
212
|
/**
|
|
203
213
|
* Hook that runs after `transformer.transform` but before the object is created.
|
|
204
214
|
* Can update the body fields based on the request or the user.
|
|
@@ -543,7 +553,10 @@ export function modelRouter<T>(
|
|
|
543
553
|
function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>): express.Router {
|
|
544
554
|
const router = express.Router();
|
|
545
555
|
|
|
546
|
-
|
|
556
|
+
assertNoActionCollisions(model, options);
|
|
557
|
+
registerActionRoutes(router, model, options);
|
|
558
|
+
|
|
559
|
+
// User endpoints run after actions; actions win on path conflicts.
|
|
547
560
|
if (options.endpoints) {
|
|
548
561
|
options.endpoints(router, options);
|
|
549
562
|
}
|