@edium/halifax 2.0.0 → 2.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/CHANGELOG.md +78 -1
- package/README.md +102 -17
- package/README_AUTH.md +38 -0
- package/README_AUTOCRUD.md +33 -0
- package/README_CLASSES.md +322 -0
- package/README_HOOKS.md +275 -0
- package/README_INTERFACES.md +601 -0
- package/README_OPENAPI.md +471 -0
- package/README_REPO_ADAPTERS.md +77 -0
- package/README_TYPES.md +114 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +128 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +255 -0
- package/dist/adapters/orm/drizzle/astToDrizzle.d.ts +21 -0
- package/dist/adapters/orm/drizzle/astToDrizzle.js +121 -0
- package/dist/adapters/orm/drizzle/index.d.ts +4 -0
- package/dist/adapters/orm/drizzle/index.js +2 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.js +24 -1
- package/dist/adapters/orm/prisma/astToPrisma.d.ts +1 -2
- package/dist/adapters/orm/prisma/astToPrisma.js +1 -3
- package/dist/adapters/orm/prisma/helpers.js +1 -1
- package/dist/adapters/orm/prisma/types.d.ts +11 -11
- package/dist/auth/AuthStrategy.d.ts +6 -189
- package/dist/auth/AuthStrategy.js +4 -220
- package/dist/auth/strategies/AllowAllAuthStrategy.d.ts +6 -0
- package/dist/auth/strategies/AllowAllAuthStrategy.js +6 -0
- package/dist/auth/strategies/ApiKeyAuthStrategy.d.ts +25 -0
- package/dist/auth/strategies/ApiKeyAuthStrategy.js +39 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.d.ts +32 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +52 -0
- package/dist/auth/strategies/PassportStrategies.d.ts +94 -0
- package/dist/auth/strategies/PassportStrategies.js +142 -0
- package/dist/auth/strategies/types.d.ts +70 -0
- package/dist/core/crudRouter.d.ts +16 -16
- package/dist/core/crudRouter.js +98 -368
- package/dist/core/fields.d.ts +8 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/handlerUtils.d.ts +70 -0
- package/dist/core/handlerUtils.js +193 -0
- package/dist/core/handlers/create.d.ts +3 -0
- package/dist/core/handlers/create.js +26 -0
- package/dist/core/handlers/deleteMany.d.ts +3 -0
- package/dist/core/handlers/deleteMany.js +24 -0
- package/dist/core/handlers/deleteOne.d.ts +3 -0
- package/dist/core/handlers/deleteOne.js +19 -0
- package/dist/core/handlers/query.d.ts +3 -0
- package/dist/core/handlers/query.js +23 -0
- package/dist/core/handlers/readMany.d.ts +3 -0
- package/dist/core/handlers/readMany.js +18 -0
- package/dist/core/handlers/readOne.d.ts +3 -0
- package/dist/core/handlers/readOne.js +23 -0
- package/dist/core/handlers/updateMany.d.ts +3 -0
- package/dist/core/handlers/updateMany.js +34 -0
- package/dist/core/handlers/updateOne.d.ts +3 -0
- package/dist/core/handlers/updateOne.js +20 -0
- package/dist/core/handlers/upsertOne.d.ts +3 -0
- package/dist/core/handlers/upsertOne.js +20 -0
- package/dist/core/hooks.d.ts +217 -0
- package/dist/core/queryString.js +1 -1
- package/dist/core/types.d.ts +48 -29
- package/dist/core/validation.d.ts +1 -2
- package/dist/core/validation.js +1 -3
- package/dist/index.d.ts +3 -6
- package/dist/index.js +3 -6
- package/dist/openapi/generateDocsHtml.d.ts +1 -0
- package/dist/openapi/generateDocsHtml.js +47 -0
- package/dist/openapi/index.d.ts +3 -0
- package/dist/openapi/index.js +2 -0
- package/dist/openapi/specGenerator.d.ts +149 -0
- package/dist/openapi/specGenerator.js +770 -0
- package/package.json +38 -22
- package/dist/enums/SqlComparison.d.ts +0 -28
- package/dist/enums/SqlComparison.js +0 -29
- package/dist/enums/SqlOperator.d.ts +0 -5
- package/dist/enums/SqlOperator.js +0 -6
- package/dist/enums/SqlOrder.d.ts +0 -5
- package/dist/enums/SqlOrder.js +0 -6
- package/dist/interfaces/IQueryFilter.d.ts +0 -17
- package/dist/interfaces/IQueryOptions.d.ts +0 -20
- package/dist/interfaces/ISort.d.ts +0 -8
- package/dist/interfaces/ISort.js +0 -1
- /package/dist/{interfaces/IQueryFilter.js → auth/strategies/types.js} +0 -0
- /package/dist/{interfaces/IQueryOptions.js → core/hooks.js} +0 -0
package/dist/core/crudRouter.js
CHANGED
|
@@ -1,57 +1,25 @@
|
|
|
1
1
|
import { AllowAllAuthStrategy } from '../auth/AuthStrategy.js';
|
|
2
2
|
import { createCachingRepository, InMemoryCacheStore } from '../core/cache/index.js';
|
|
3
|
-
import {
|
|
4
|
-
import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
|
|
5
|
-
import { NotAcceptableError } from '../errors/NotAcceptableError.js';
|
|
6
|
-
import { NotFoundError } from '../errors/NotFoundError.js';
|
|
7
|
-
import { NotImplementedError } from '../errors/NotImplementedError.js';
|
|
8
|
-
import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
|
|
9
|
-
import { UnsupportedMediaTypeError } from '../errors/UnsupportedMediaTypeError.js';
|
|
3
|
+
import { generateOpenApiSpec, generateDocsHtml } from '../openapi/index.js';
|
|
10
4
|
import { defaultCrudPermissions } from '../core/types.js';
|
|
11
|
-
import { parseListOptions } from '../core/queryString.js';
|
|
12
|
-
import { validateAdvancedQuery, validateId, isValidUuid, isValidObjectId } from '../core/validation.js';
|
|
13
5
|
import { ServerError } from '../errors/ServerError.js';
|
|
14
6
|
import { AuthorizationError } from '../errors/AuthorizationError.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Strips non-writable fields from a request body and rejects unknown fields with a 422.
|
|
30
|
-
* Operates on a {@link normalizeResource | normalized} resource, whose fields carry explicit
|
|
31
|
-
* `writable` booleans (permissive by default; the primary key protected). Fields with
|
|
32
|
-
* `writable: false` are silently dropped.
|
|
33
|
-
* @param resource - The normalized resource definition.
|
|
34
|
-
* @param data - The raw request body key-value map.
|
|
35
|
-
* @returns A new object containing only writable fields.
|
|
36
|
-
* @throws {@link UnprocessableEntityError} when the body contains keys not defined on the resource.
|
|
37
|
-
*/
|
|
38
|
-
function filterWritableFields(resource, data) {
|
|
39
|
-
const fields = resource.fields ?? [];
|
|
40
|
-
const knownFields = new Set(fields.map((f) => f.name));
|
|
41
|
-
const unknownFields = Object.keys(data).filter((key) => !knownFields.has(key));
|
|
42
|
-
if (unknownFields.length) {
|
|
43
|
-
throw new UnprocessableEntityError(`Unknown field(s): ${unknownFields.join(', ')}.`);
|
|
44
|
-
}
|
|
45
|
-
return Object.fromEntries(Object.entries(data).filter(([key]) => {
|
|
46
|
-
const field = fields.find((f) => f.name === key);
|
|
47
|
-
return field?.writable !== false;
|
|
48
|
-
}));
|
|
49
|
-
}
|
|
7
|
+
import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
|
|
8
|
+
import { normalizeError, sendError, wantsCacheBust } from '../core/handlerUtils.js';
|
|
9
|
+
import { mergeFieldDefinitions } from '../core/fields.js';
|
|
10
|
+
import { registerCreate } from '../core/handlers/create.js';
|
|
11
|
+
import { registerReadMany } from '../core/handlers/readMany.js';
|
|
12
|
+
import { registerReadOne } from '../core/handlers/readOne.js';
|
|
13
|
+
import { registerQuery } from '../core/handlers/query.js';
|
|
14
|
+
import { registerUpdateOne } from '../core/handlers/updateOne.js';
|
|
15
|
+
import { registerUpdateMany } from '../core/handlers/updateMany.js';
|
|
16
|
+
import { registerUpsertOne } from '../core/handlers/upsertOne.js';
|
|
17
|
+
import { registerDeleteOne } from '../core/handlers/deleteOne.js';
|
|
18
|
+
import { registerDeleteMany } from '../core/handlers/deleteMany.js';
|
|
19
|
+
export { normalizeError };
|
|
50
20
|
/**
|
|
51
21
|
* Derives a human-readable resource name from a route prefix when none is given:
|
|
52
22
|
* de-kebabs/de-snakes and title-cases each word (`'blog-posts'` → `'Blog Posts'`).
|
|
53
|
-
* @param routePrefix - The resource's URL path segment.
|
|
54
|
-
* @returns A title-cased display name.
|
|
55
23
|
*/
|
|
56
24
|
function deriveResourceName(routePrefix) {
|
|
57
25
|
return (routePrefix
|
|
@@ -61,27 +29,13 @@ function deriveResourceName(routePrefix) {
|
|
|
61
29
|
.join(' ') || routePrefix);
|
|
62
30
|
}
|
|
63
31
|
/**
|
|
64
|
-
* Resolves the effective field list for a resource.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* exposes no schema, the resource's `fields` are the authoritative list.
|
|
68
|
-
*
|
|
69
|
-
* Every resolved field gets explicit, permissive defaults — `filterable`/`sortable`/
|
|
70
|
-
* `selectable`/`writable` default to `true` — except the primary key, which is non-writable
|
|
71
|
-
* unless a field entry explicitly sets `writable: true`.
|
|
72
|
-
*
|
|
73
|
-
* @param resource - The raw resource definition.
|
|
74
|
-
* @param idField - The repository's primary-key field name (protected from writes).
|
|
75
|
-
* @returns The fully-resolved field list.
|
|
32
|
+
* Resolves the effective field list for a resource. Merges the repository's field schema
|
|
33
|
+
* with the resource's own `fields` as sparse overrides. Applies permissive defaults for all
|
|
34
|
+
* flags except the primary key, which is non-writable unless explicitly opted in.
|
|
76
35
|
* @throws {@link ServerError} when neither the repository nor the resource provides any fields.
|
|
77
36
|
*/
|
|
78
37
|
function resolveFields(resource, idField) {
|
|
79
|
-
const
|
|
80
|
-
for (const field of resource.repository?.fields ?? [])
|
|
81
|
-
byName.set(field.name, { ...field });
|
|
82
|
-
for (const override of resource.fields ?? [])
|
|
83
|
-
byName.set(override.name, { ...byName.get(override.name), ...override });
|
|
84
|
-
const merged = [...byName.values()];
|
|
38
|
+
const merged = mergeFieldDefinitions(resource);
|
|
85
39
|
if (merged.length === 0) {
|
|
86
40
|
throw new ServerError(`Resource '${resource.name ?? resource.routePrefix}' has no fields. Provide 'fields', ` +
|
|
87
41
|
`or construct its repository with a model so the schema can be derived.`);
|
|
@@ -92,13 +46,15 @@ function resolveFields(resource, idField) {
|
|
|
92
46
|
sortable: field.sortable !== false,
|
|
93
47
|
selectable: field.selectable !== false,
|
|
94
48
|
// Permissive by default — but the primary key is protected: writable only when opted in.
|
|
95
|
-
writable: field.name === idField ? field.writable === true : field.writable !== false
|
|
49
|
+
writable: field.name === idField ? field.writable === true : field.writable !== false,
|
|
50
|
+
...(field.type !== undefined ? { type: field.type } : {}),
|
|
51
|
+
...(field.format !== undefined ? { format: field.format } : {}),
|
|
52
|
+
...(field.readRoles?.length ? { readRoles: field.readRoles } : {}),
|
|
53
|
+
...(field.writeRoles?.length ? { writeRoles: field.writeRoles } : {})
|
|
96
54
|
}));
|
|
97
55
|
}
|
|
98
56
|
/**
|
|
99
57
|
* Merges the repository's relation schema with the resource's own relations, by name.
|
|
100
|
-
* @param resource - The raw resource definition.
|
|
101
|
-
* @returns The resolved relation list (may be empty).
|
|
102
58
|
*/
|
|
103
59
|
function resolveRelations(resource) {
|
|
104
60
|
const byName = new Map();
|
|
@@ -109,12 +65,9 @@ function resolveRelations(resource) {
|
|
|
109
65
|
return [...byName.values()];
|
|
110
66
|
}
|
|
111
67
|
/**
|
|
112
|
-
* Produces a fully-resolved resource: `name` filled in
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* so defaults live in exactly one place.
|
|
116
|
-
* @param resource - The raw resource definition supplied by the caller.
|
|
117
|
-
* @returns A normalized resource with guaranteed `name` and resolved `fields`/`relations`.
|
|
68
|
+
* Produces a fully-resolved resource: `name` filled in, and `fields`/`relations` resolved
|
|
69
|
+
* from the repository schema + the resource's own entries. Every downstream stage operates
|
|
70
|
+
* on this normalized form so defaults live in exactly one place.
|
|
118
71
|
*/
|
|
119
72
|
function normalizeResource(resource) {
|
|
120
73
|
const idField = resource.repository?.idField ?? 'id';
|
|
@@ -125,89 +78,17 @@ function normalizeResource(resource) {
|
|
|
125
78
|
relations: resolveRelations(resource)
|
|
126
79
|
};
|
|
127
80
|
}
|
|
128
|
-
/** Maps HTTP status codes to machine-readable error code strings. */
|
|
129
|
-
const statusCodeMap = {
|
|
130
|
-
400: 'BAD_REQUEST',
|
|
131
|
-
401: 'UNAUTHORIZED',
|
|
132
|
-
403: 'FORBIDDEN',
|
|
133
|
-
404: 'NOT_FOUND',
|
|
134
|
-
405: 'METHOD_NOT_ALLOWED',
|
|
135
|
-
406: 'NOT_ACCEPTABLE',
|
|
136
|
-
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
137
|
-
422: 'UNPROCESSABLE_ENTITY',
|
|
138
|
-
501: 'NOT_IMPLEMENTED'
|
|
139
|
-
};
|
|
140
|
-
/**
|
|
141
|
-
* Converts any thrown value to a structured `{ status, code, message, details }` object.
|
|
142
|
-
* {@link HttpError} subclasses preserve their status; all other errors become 500.
|
|
143
|
-
* @param error - The caught value to normalise (may be any type).
|
|
144
|
-
* @returns A plain object with `status`, `code`, `message`, and optional `details`.
|
|
145
|
-
*/
|
|
146
|
-
export function normalizeError(error) {
|
|
147
|
-
if (error instanceof HttpError) {
|
|
148
|
-
return {
|
|
149
|
-
status: error.status,
|
|
150
|
-
code: statusCodeMap[error.status] ?? 'INTERNAL_ERROR',
|
|
151
|
-
message: error.message,
|
|
152
|
-
details: error.details
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
if (error instanceof Error) {
|
|
156
|
-
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
|
|
157
|
-
}
|
|
158
|
-
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Serialises a caught error and writes it as a JSON `{ errors: [...] }` response.
|
|
162
|
-
* @param error - The caught value to serialise.
|
|
163
|
-
* @param res - The response object to write to.
|
|
164
|
-
*/
|
|
165
|
-
async function sendError(error, res) {
|
|
166
|
-
const { status, code, message, details } = normalizeError(error);
|
|
167
|
-
const item = { code, message };
|
|
168
|
-
if (details !== undefined)
|
|
169
|
-
item['details'] = details;
|
|
170
|
-
await res.status(status).json({ errors: [item] });
|
|
171
|
-
}
|
|
172
81
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* @param resource - The resource being accessed (used to look up required permissions).
|
|
176
|
-
* @param action - The CRUD action being performed.
|
|
177
|
-
* @param authStrategy - The active auth strategy.
|
|
178
|
-
* @returns The resolved {@link AuthContext} (so callers can derive the tenant scope from it).
|
|
82
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping; `null`, `undefined`,
|
|
83
|
+
* and `''` all mean "no envelope".
|
|
179
84
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const requiredPermissions = resource.requiredPermissions?.[action] ?? [];
|
|
183
|
-
if (authStrategy.authorize) {
|
|
184
|
-
const allowed = await authStrategy.authorize({
|
|
185
|
-
auth,
|
|
186
|
-
action,
|
|
187
|
-
resource,
|
|
188
|
-
requiredPermissions,
|
|
189
|
-
req
|
|
190
|
-
});
|
|
191
|
-
if (!allowed)
|
|
192
|
-
throw new AuthorizationError();
|
|
193
|
-
return auth;
|
|
194
|
-
}
|
|
195
|
-
if (requiredPermissions.length) {
|
|
196
|
-
const permissions = new Set(auth.permissions ?? []);
|
|
197
|
-
const roles = new Set(auth.roles ?? []);
|
|
198
|
-
const allowed = requiredPermissions.every((permission) => permissions.has(permission) || roles.has(permission));
|
|
199
|
-
if (!allowed)
|
|
200
|
-
throw new AuthorizationError();
|
|
201
|
-
}
|
|
202
|
-
return auth;
|
|
85
|
+
function normalizeEnvelope(value) {
|
|
86
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
203
87
|
}
|
|
204
88
|
/**
|
|
205
|
-
* Determines the column a resource is tenant-scoped on,
|
|
89
|
+
* Determines the column a resource is tenant-scoped on, with this precedence:
|
|
206
90
|
* explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
|
|
207
91
|
* tenant field when the resource actually has it → otherwise unscoped (global).
|
|
208
|
-
* @param resource - The resource being inspected.
|
|
209
|
-
* @param tenant - The API-wide tenant options, or `undefined` when tenancy is off.
|
|
210
|
-
* @returns The tenant field name, or `null` when the resource is not scoped.
|
|
211
92
|
*/
|
|
212
93
|
function effectiveTenantField(resource, tenant) {
|
|
213
94
|
if (!tenant)
|
|
@@ -221,87 +102,14 @@ function effectiveTenantField(resource, tenant) {
|
|
|
221
102
|
}
|
|
222
103
|
/** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
|
|
223
104
|
const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
224
|
-
/**
|
|
225
|
-
* Reads a single header value by name (case-insensitive).
|
|
226
|
-
* @param req - The incoming HTTP request.
|
|
227
|
-
* @param name - Header name to look up (case-insensitive).
|
|
228
|
-
* @returns The header value as a string, or `undefined` when absent.
|
|
229
|
-
*/
|
|
230
|
-
function getHeaderValue(req, name) {
|
|
231
|
-
const raw = req.headers[name.toLowerCase()] ?? req.headers[name];
|
|
232
|
-
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
233
|
-
return typeof value === 'string' ? value : undefined;
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Decides whether the request asks to bypass (bust) the cache. For the standard
|
|
237
|
-
* `Cache-Control` header this means a `no-cache`/`no-store` directive; for any custom
|
|
238
|
-
* bust header, mere presence (with a non-empty value) triggers a refresh.
|
|
239
|
-
* @param req - The incoming HTTP request.
|
|
240
|
-
* @param header - The configured bust header name.
|
|
241
|
-
* @returns `true` when the cache should be force-refreshed for this request.
|
|
242
|
-
*/
|
|
243
|
-
function wantsCacheBust(req, header) {
|
|
244
|
-
const value = getHeaderValue(req, header);
|
|
245
|
-
if (!value)
|
|
246
|
-
return false;
|
|
247
|
-
if (header.toLowerCase() === 'cache-control')
|
|
248
|
-
return /no-cache|no-store/i.test(value);
|
|
249
|
-
return true;
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Throws {@link UnsupportedMediaTypeError} when a body-carrying request uses a non-JSON Content-Type.
|
|
253
|
-
* @param req - The incoming HTTP request to check.
|
|
254
|
-
*/
|
|
255
|
-
function checkContentType(req) {
|
|
256
|
-
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method.toUpperCase()))
|
|
257
|
-
return;
|
|
258
|
-
const contentType = getHeaderValue(req, 'content-type') ?? '';
|
|
259
|
-
if (contentType && !contentType.includes('application/json')) {
|
|
260
|
-
throw new UnsupportedMediaTypeError();
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Throws {@link NotAcceptableError} when the client's Accept header excludes `application/json`.
|
|
265
|
-
* @param req - The incoming HTTP request to check.
|
|
266
|
-
*/
|
|
267
|
-
function checkAcceptHeader(req) {
|
|
268
|
-
const accept = getHeaderValue(req, 'accept') ?? '';
|
|
269
|
-
if (accept &&
|
|
270
|
-
!accept.includes('*/*') &&
|
|
271
|
-
!accept.includes('application/*') &&
|
|
272
|
-
!accept.includes('application/json')) {
|
|
273
|
-
throw new NotAcceptableError();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Wraps a route handler with Content-Type / Accept checks, error serialisation,
|
|
278
|
-
* and `X-Correlation-ID` echo-back.
|
|
279
|
-
* @param handler - The inner async route handler to wrap.
|
|
280
|
-
* @returns A new handler with pre/post-processing applied.
|
|
281
|
-
*/
|
|
282
|
-
function wrap(handler) {
|
|
283
|
-
return async (req, res) => {
|
|
284
|
-
const correlationId = getHeaderValue(req, 'x-correlation-id');
|
|
285
|
-
if (correlationId)
|
|
286
|
-
res.setHeader?.('X-Correlation-ID', correlationId);
|
|
287
|
-
try {
|
|
288
|
-
checkContentType(req);
|
|
289
|
-
checkAcceptHeader(req);
|
|
290
|
-
await handler(req, res);
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
await sendError(error, res);
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
105
|
/**
|
|
298
106
|
* Registers all CRUD routes for every resource on the given HTTP server.
|
|
299
107
|
*
|
|
300
108
|
* Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
|
|
301
109
|
*
|
|
302
|
-
* @param server - The HTTP server adapter to register routes on
|
|
110
|
+
* @param server - The HTTP server adapter to register routes on.
|
|
303
111
|
* @param resources - Resource definitions to wire up as CRUD endpoints.
|
|
304
|
-
* @param options - Auth strategy and
|
|
112
|
+
* @param options - Auth strategy, tenant config, envelope, caching, and OpenAPI overrides.
|
|
305
113
|
*/
|
|
306
114
|
export function registerCrudApi(server, resources, options = {}) {
|
|
307
115
|
const authStrategy = options.authStrategy ?? new AllowAllAuthStrategy();
|
|
@@ -314,12 +122,12 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
314
122
|
const repository = rawResource.repository;
|
|
315
123
|
if (!repository)
|
|
316
124
|
throw new ServerError(`Resource '${rawResource.name ?? rawResource.routePrefix}' does not define a repository.`);
|
|
317
|
-
// Resolve name + field/relation schema once
|
|
318
|
-
//
|
|
319
|
-
// truth with all defaults already applied.
|
|
125
|
+
// Resolve name + field/relation schema once so every downstream stage works off a single
|
|
126
|
+
// source of truth with all defaults already applied.
|
|
320
127
|
const resource = normalizeResource(rawResource);
|
|
321
|
-
//
|
|
322
|
-
|
|
128
|
+
// Per-resource envelope wins over API-wide default, including an explicit null/''.
|
|
129
|
+
const envelope = normalizeEnvelope(resource.envelope !== undefined ? resource.envelope : options.envelope);
|
|
130
|
+
// Resolve tenancy once at registration and fail closed on misconfiguration.
|
|
323
131
|
const tenantField = effectiveTenantField(resource, options.tenant);
|
|
324
132
|
if (tenantField) {
|
|
325
133
|
if (!safeIdentifier.test(tenantField))
|
|
@@ -328,21 +136,12 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
328
136
|
throw new ServerError(`Resource '${resource.name}' is tenant-scoped on '${tenantField}' but its repository ` +
|
|
329
137
|
`does not implement withScope(). Refusing to serve it unscoped.`);
|
|
330
138
|
}
|
|
331
|
-
// Effective cache TTL:
|
|
332
|
-
//
|
|
139
|
+
// Effective cache TTL: explicit `cache: false` disables; per-resource config wins over
|
|
140
|
+
// API-wide default. `0` means "never expire" (so `undefined` = no caching).
|
|
333
141
|
const cacheTtl = resource.cache === false
|
|
334
142
|
? undefined
|
|
335
143
|
: (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
|
|
336
144
|
const cachingEnabled = cacheTtl !== undefined;
|
|
337
|
-
/**
|
|
338
|
-
* Wraps a repository in the read-through cache when caching is enabled for this resource.
|
|
339
|
-
* The namespace embeds the resource name and tenant key, so one tenant can never read
|
|
340
|
-
* another's cached rows. `bust` (from the cache-bust header) force-refreshes this request.
|
|
341
|
-
* @param repo - The (possibly tenant-scoped) repository for this request.
|
|
342
|
-
* @param scopeKey - Stable key for the current tenant scope (or `'global'`).
|
|
343
|
-
* @param bust - Whether the caller requested a cache-busting refresh.
|
|
344
|
-
* @returns The repository, cache-wrapped when caching is enabled.
|
|
345
|
-
*/
|
|
346
145
|
const withCache = (repo, scopeKey, bust) => cachingEnabled
|
|
347
146
|
? createCachingRepository(repo, {
|
|
348
147
|
store: cacheStore,
|
|
@@ -351,12 +150,6 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
351
150
|
bust
|
|
352
151
|
})
|
|
353
152
|
: repo;
|
|
354
|
-
/**
|
|
355
|
-
* Returns the repository to use for this request: the tenant-scoped clone when the
|
|
356
|
-
* resource is scoped, or the bare repository otherwise — wrapped in the read-through
|
|
357
|
-
* cache when enabled. Throws 403 in strict mode (the default) when a scoped resource
|
|
358
|
-
* cannot resolve a tenant for the caller.
|
|
359
|
-
*/
|
|
360
153
|
const resolveRepo = async (req, auth) => {
|
|
361
154
|
const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
|
|
362
155
|
if (!tenantField || !options.tenant)
|
|
@@ -371,127 +164,27 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
371
164
|
};
|
|
372
165
|
const permissions = { ...defaultCrudPermissions, ...resource.permissions };
|
|
373
166
|
const basePath = `/${resource.routePrefix}`;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const results = await repo.getMany(listOptions);
|
|
396
|
-
await res.status(200).json(results);
|
|
397
|
-
}));
|
|
398
|
-
}
|
|
399
|
-
if (permissions.allowReadManyWithQueryBuilder) {
|
|
400
|
-
server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
|
|
401
|
-
const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
|
|
402
|
-
const repo = await resolveRepo(req, auth);
|
|
403
|
-
if (!repo.executeQuery)
|
|
404
|
-
throw new NotImplementedError('This resource does not support the query builder.');
|
|
405
|
-
const body = (req.body ?? {});
|
|
406
|
-
const query = { ...body };
|
|
407
|
-
validateAdvancedQuery(resource, query);
|
|
408
|
-
const results = await repo.executeQuery(query);
|
|
409
|
-
await res.status(200).json(results);
|
|
410
|
-
}));
|
|
411
|
-
}
|
|
412
|
-
if (permissions.allowReadOne) {
|
|
413
|
-
server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
|
|
414
|
-
const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
|
|
415
|
-
const repo = await resolveRepo(req, auth);
|
|
416
|
-
const id = parseId(req.params['id']);
|
|
417
|
-
const listOptions = parseListOptions(req.query, resource);
|
|
418
|
-
const result = await repo.getOne(id, {
|
|
419
|
-
fields: listOptions.fields,
|
|
420
|
-
include: listOptions.include
|
|
421
|
-
});
|
|
422
|
-
if (!result)
|
|
423
|
-
throw new NotFoundError();
|
|
424
|
-
await res.status(200).json(result);
|
|
425
|
-
}));
|
|
426
|
-
}
|
|
427
|
-
if (permissions.allowUpdateOne) {
|
|
428
|
-
server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
|
|
429
|
-
const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
|
|
430
|
-
const repo = await resolveRepo(req, auth);
|
|
431
|
-
const id = parseId(req.params['id']);
|
|
432
|
-
const body = filterWritableFields(resource, req.body);
|
|
433
|
-
const result = await repo.updateOne(id, body);
|
|
434
|
-
if (!result)
|
|
435
|
-
throw new NotFoundError();
|
|
436
|
-
await res.status(200).json(result);
|
|
437
|
-
}));
|
|
438
|
-
}
|
|
439
|
-
if (permissions.allowUpdateMany) {
|
|
440
|
-
server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
|
|
441
|
-
const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
|
|
442
|
-
const repo = await resolveRepo(req, auth);
|
|
443
|
-
if (!repo.updateMany)
|
|
444
|
-
throw new NotImplementedError('This resource does not support updateMany.');
|
|
445
|
-
const { update, ...queryBody } = (req.body ?? {});
|
|
446
|
-
const filteredUpdate = filterWritableFields(resource, (update ?? {}));
|
|
447
|
-
if (!Object.keys(filteredUpdate).length)
|
|
448
|
-
throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
|
|
449
|
-
const query = { ...queryBody };
|
|
450
|
-
validateAdvancedQuery(resource, query);
|
|
451
|
-
if (!query.where?.length)
|
|
452
|
-
throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
|
|
453
|
-
const result = await repo.updateMany(query, filteredUpdate);
|
|
454
|
-
await res.status(200).json(result);
|
|
455
|
-
}));
|
|
456
|
-
}
|
|
457
|
-
if (permissions.allowUpsertOne) {
|
|
458
|
-
server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
|
|
459
|
-
const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
|
|
460
|
-
const repo = await resolveRepo(req, auth);
|
|
461
|
-
if (!repo.upsertOne)
|
|
462
|
-
throw new NotImplementedError('This resource does not support upsert.');
|
|
463
|
-
const id = parseId(req.params['id']);
|
|
464
|
-
const body = filterWritableFields(resource, req.body);
|
|
465
|
-
const result = await repo.upsertOne(id, body);
|
|
466
|
-
await res.status(200).json(result);
|
|
467
|
-
}));
|
|
468
|
-
}
|
|
469
|
-
if (permissions.allowDeleteOne) {
|
|
470
|
-
server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
|
|
471
|
-
const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
|
|
472
|
-
const repo = await resolveRepo(req, auth);
|
|
473
|
-
const id = parseId(req.params['id']);
|
|
474
|
-
const deleted = await repo.deleteOne(id);
|
|
475
|
-
if (!deleted)
|
|
476
|
-
throw new NotFoundError();
|
|
477
|
-
await res.status(200).json({ deleted: true });
|
|
478
|
-
}));
|
|
479
|
-
}
|
|
480
|
-
if (permissions.allowDeleteMany) {
|
|
481
|
-
server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
|
|
482
|
-
const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
|
|
483
|
-
const repo = await resolveRepo(req, auth);
|
|
484
|
-
if (!repo.deleteMany)
|
|
485
|
-
throw new NotImplementedError('This resource does not support deleteMany.');
|
|
486
|
-
const body = (req.body ?? {});
|
|
487
|
-
const query = { ...body };
|
|
488
|
-
validateAdvancedQuery(resource, query);
|
|
489
|
-
if (!query.where?.length)
|
|
490
|
-
throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
|
|
491
|
-
const result = await repo.deleteMany(query);
|
|
492
|
-
await res.status(200).json(result);
|
|
493
|
-
}));
|
|
494
|
-
}
|
|
167
|
+
// Cast to the widest usable type once so every handler can call hooks without generics.
|
|
168
|
+
const hooks = resource.hooks;
|
|
169
|
+
const handlerCtx = { resource, authStrategy, envelope, hooks, resolveRepo };
|
|
170
|
+
if (permissions.allowCreate)
|
|
171
|
+
registerCreate(server, basePath, handlerCtx);
|
|
172
|
+
if (permissions.allowReadMany)
|
|
173
|
+
registerReadMany(server, basePath, handlerCtx);
|
|
174
|
+
if (permissions.allowReadManyWithQueryBuilder)
|
|
175
|
+
registerQuery(server, basePath, queryBuilderPath, handlerCtx);
|
|
176
|
+
if (permissions.allowReadOne)
|
|
177
|
+
registerReadOne(server, basePath, handlerCtx);
|
|
178
|
+
if (permissions.allowUpdateOne)
|
|
179
|
+
registerUpdateOne(server, basePath, handlerCtx);
|
|
180
|
+
if (permissions.allowUpdateMany)
|
|
181
|
+
registerUpdateMany(server, basePath, handlerCtx);
|
|
182
|
+
if (permissions.allowUpsertOne)
|
|
183
|
+
registerUpsertOne(server, basePath, handlerCtx);
|
|
184
|
+
if (permissions.allowDeleteOne)
|
|
185
|
+
registerDeleteOne(server, basePath, handlerCtx);
|
|
186
|
+
if (permissions.allowDeleteMany)
|
|
187
|
+
registerDeleteMany(server, basePath, handlerCtx);
|
|
495
188
|
// 405 fallbacks — only registered when at least one method exists for the path
|
|
496
189
|
const baseMethods = [
|
|
497
190
|
...(permissions.allowReadMany ? ['GET'] : []),
|
|
@@ -524,4 +217,41 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
524
217
|
});
|
|
525
218
|
}
|
|
526
219
|
});
|
|
220
|
+
if (options.openapi && options.openapi.enabled !== false) {
|
|
221
|
+
const specPath = options.openapi.specPath ?? '/openapi.json';
|
|
222
|
+
const docsPath = options.openapi.docsPath ?? '/docs';
|
|
223
|
+
const resolvedEnvelope = options.openapi.envelope ?? options.envelope ?? null;
|
|
224
|
+
const resolvedScheme = options.openapi.securityScheme ?? authStrategy.openApiScheme?.();
|
|
225
|
+
const openApiOpts = {
|
|
226
|
+
...options.openapi,
|
|
227
|
+
envelope: resolvedEnvelope,
|
|
228
|
+
...(resolvedScheme ? { securityScheme: resolvedScheme } : {})
|
|
229
|
+
};
|
|
230
|
+
const spec = generateOpenApiSpec(resources, openApiOpts);
|
|
231
|
+
const specJson = JSON.stringify(spec, null, 2);
|
|
232
|
+
const docsHtml = generateDocsHtml(specPath, docsPath);
|
|
233
|
+
const requireAuth = options.openapi.requireAuth === true;
|
|
234
|
+
server.registerRoute('GET', specPath, async (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
if (requireAuth)
|
|
237
|
+
await authStrategy.authenticate(req);
|
|
238
|
+
res.setHeader?.('Content-Type', 'application/json');
|
|
239
|
+
res.send?.(specJson);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
await sendError(error, res);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.registerRoute('GET', docsPath, async (req, res) => {
|
|
246
|
+
try {
|
|
247
|
+
if (requireAuth)
|
|
248
|
+
await authStrategy.authenticate(req);
|
|
249
|
+
res.setHeader?.('Content-Type', 'text/html; charset=utf-8');
|
|
250
|
+
res.send?.(docsHtml);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
await sendError(error, res);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
527
257
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Merges a resource's field schema: repository fields are the base, and
|
|
4
|
+
* `resource.fields` entries are applied as sparse overrides (by name).
|
|
5
|
+
* Returns the raw merged list with no defaults applied — callers normalise
|
|
6
|
+
* the flags they care about on top of this.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mergeFieldDefinitions(resource: ResourceDefinition): FieldDefinition[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges a resource's field schema: repository fields are the base, and
|
|
3
|
+
* `resource.fields` entries are applied as sparse overrides (by name).
|
|
4
|
+
* Returns the raw merged list with no defaults applied — callers normalise
|
|
5
|
+
* the flags they care about on top of this.
|
|
6
|
+
*/
|
|
7
|
+
export function mergeFieldDefinitions(resource) {
|
|
8
|
+
const byName = new Map();
|
|
9
|
+
for (const f of resource.repository?.fields ?? [])
|
|
10
|
+
byName.set(f.name, { ...f });
|
|
11
|
+
for (const f of resource.fields ?? [])
|
|
12
|
+
byName.set(f.name, { ...byName.get(f.name), ...f });
|
|
13
|
+
return [...byName.values()];
|
|
14
|
+
}
|