@edium/halifax 2.1.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 +64 -1
- package/README.md +102 -17
- package/README_AUTH.md +38 -0
- package/README_AUTOCRUD.md +5 -5
- 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 +11 -18
- package/dist/core/crudRouter.js +95 -390
- 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 +38 -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,111 +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
81
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
/**
|
|
173
|
-
* Resolves the effective envelope key: a non-empty string enables wrapping; `null`, `undefined`,
|
|
174
|
-
* and `''` all mean "no envelope" (an empty key would produce a meaningless `{ "": body }`).
|
|
175
|
-
* @param value - The per-resource or API-wide envelope setting.
|
|
176
|
-
* @returns The envelope key, or `null` when responses should be sent bare.
|
|
82
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping; `null`, `undefined`,
|
|
83
|
+
* and `''` all mean "no envelope".
|
|
177
84
|
*/
|
|
178
85
|
function normalizeEnvelope(value) {
|
|
179
86
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
180
87
|
}
|
|
181
88
|
/**
|
|
182
|
-
*
|
|
183
|
-
* The single seam through which every success response is serialised, so the envelope is
|
|
184
|
-
* applied consistently and exactly once. Applied at the response boundary (after the cache),
|
|
185
|
-
* so cached payloads stay envelope-agnostic.
|
|
186
|
-
* @param res - The response object to write to.
|
|
187
|
-
* @param status - HTTP status code to send.
|
|
188
|
-
* @param body - The success payload (wrapped under `envelope` when set).
|
|
189
|
-
* @param envelope - Resolved envelope key, or `null` to send the body bare.
|
|
190
|
-
*/
|
|
191
|
-
function writeSuccess(res, status, body, envelope) {
|
|
192
|
-
return res.status(status).json(envelope ? { [envelope]: body } : body);
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
|
|
196
|
-
* @param req - The incoming HTTP request.
|
|
197
|
-
* @param resource - The resource being accessed (used to look up required permissions).
|
|
198
|
-
* @param action - The CRUD action being performed.
|
|
199
|
-
* @param authStrategy - The active auth strategy.
|
|
200
|
-
* @returns The resolved {@link AuthContext} (so callers can derive the tenant scope from it).
|
|
201
|
-
*/
|
|
202
|
-
async function authorizeRequest(req, resource, action, authStrategy) {
|
|
203
|
-
const auth = await authStrategy.authenticate(req);
|
|
204
|
-
const requiredPermissions = resource.requiredPermissions?.[action] ?? [];
|
|
205
|
-
if (authStrategy.authorize) {
|
|
206
|
-
const allowed = await authStrategy.authorize({
|
|
207
|
-
auth,
|
|
208
|
-
action,
|
|
209
|
-
resource,
|
|
210
|
-
requiredPermissions,
|
|
211
|
-
req
|
|
212
|
-
});
|
|
213
|
-
if (!allowed)
|
|
214
|
-
throw new AuthorizationError();
|
|
215
|
-
return auth;
|
|
216
|
-
}
|
|
217
|
-
if (requiredPermissions.length) {
|
|
218
|
-
const permissions = new Set(auth.permissions ?? []);
|
|
219
|
-
const roles = new Set(auth.roles ?? []);
|
|
220
|
-
const allowed = requiredPermissions.every((permission) => permissions.has(permission) || roles.has(permission));
|
|
221
|
-
if (!allowed)
|
|
222
|
-
throw new AuthorizationError();
|
|
223
|
-
}
|
|
224
|
-
return auth;
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Determines the column a resource is tenant-scoped on, applying this precedence:
|
|
89
|
+
* Determines the column a resource is tenant-scoped on, with this precedence:
|
|
228
90
|
* explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
|
|
229
91
|
* tenant field when the resource actually has it → otherwise unscoped (global).
|
|
230
|
-
* @param resource - The resource being inspected.
|
|
231
|
-
* @param tenant - The API-wide tenant options, or `undefined` when tenancy is off.
|
|
232
|
-
* @returns The tenant field name, or `null` when the resource is not scoped.
|
|
233
92
|
*/
|
|
234
93
|
function effectiveTenantField(resource, tenant) {
|
|
235
94
|
if (!tenant)
|
|
@@ -243,87 +102,14 @@ function effectiveTenantField(resource, tenant) {
|
|
|
243
102
|
}
|
|
244
103
|
/** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
|
|
245
104
|
const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
246
|
-
/**
|
|
247
|
-
* Reads a single header value by name (case-insensitive).
|
|
248
|
-
* @param req - The incoming HTTP request.
|
|
249
|
-
* @param name - Header name to look up (case-insensitive).
|
|
250
|
-
* @returns The header value as a string, or `undefined` when absent.
|
|
251
|
-
*/
|
|
252
|
-
function getHeaderValue(req, name) {
|
|
253
|
-
const raw = req.headers[name.toLowerCase()] ?? req.headers[name];
|
|
254
|
-
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
255
|
-
return typeof value === 'string' ? value : undefined;
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Decides whether the request asks to bypass (bust) the cache. For the standard
|
|
259
|
-
* `Cache-Control` header this means a `no-cache`/`no-store` directive; for any custom
|
|
260
|
-
* bust header, mere presence (with a non-empty value) triggers a refresh.
|
|
261
|
-
* @param req - The incoming HTTP request.
|
|
262
|
-
* @param header - The configured bust header name.
|
|
263
|
-
* @returns `true` when the cache should be force-refreshed for this request.
|
|
264
|
-
*/
|
|
265
|
-
function wantsCacheBust(req, header) {
|
|
266
|
-
const value = getHeaderValue(req, header);
|
|
267
|
-
if (!value)
|
|
268
|
-
return false;
|
|
269
|
-
if (header.toLowerCase() === 'cache-control')
|
|
270
|
-
return /no-cache|no-store/i.test(value);
|
|
271
|
-
return true;
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Throws {@link UnsupportedMediaTypeError} when a body-carrying request uses a non-JSON Content-Type.
|
|
275
|
-
* @param req - The incoming HTTP request to check.
|
|
276
|
-
*/
|
|
277
|
-
function checkContentType(req) {
|
|
278
|
-
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method.toUpperCase()))
|
|
279
|
-
return;
|
|
280
|
-
const contentType = getHeaderValue(req, 'content-type') ?? '';
|
|
281
|
-
if (contentType && !contentType.includes('application/json')) {
|
|
282
|
-
throw new UnsupportedMediaTypeError();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Throws {@link NotAcceptableError} when the client's Accept header excludes `application/json`.
|
|
287
|
-
* @param req - The incoming HTTP request to check.
|
|
288
|
-
*/
|
|
289
|
-
function checkAcceptHeader(req) {
|
|
290
|
-
const accept = getHeaderValue(req, 'accept') ?? '';
|
|
291
|
-
if (accept &&
|
|
292
|
-
!accept.includes('*/*') &&
|
|
293
|
-
!accept.includes('application/*') &&
|
|
294
|
-
!accept.includes('application/json')) {
|
|
295
|
-
throw new NotAcceptableError();
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Wraps a route handler with Content-Type / Accept checks, error serialisation,
|
|
300
|
-
* and `X-Correlation-ID` echo-back.
|
|
301
|
-
* @param handler - The inner async route handler to wrap.
|
|
302
|
-
* @returns A new handler with pre/post-processing applied.
|
|
303
|
-
*/
|
|
304
|
-
function wrap(handler) {
|
|
305
|
-
return async (req, res) => {
|
|
306
|
-
const correlationId = getHeaderValue(req, 'x-correlation-id');
|
|
307
|
-
if (correlationId)
|
|
308
|
-
res.setHeader?.('X-Correlation-ID', correlationId);
|
|
309
|
-
try {
|
|
310
|
-
checkContentType(req);
|
|
311
|
-
checkAcceptHeader(req);
|
|
312
|
-
await handler(req, res);
|
|
313
|
-
}
|
|
314
|
-
catch (error) {
|
|
315
|
-
await sendError(error, res);
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
105
|
/**
|
|
320
106
|
* Registers all CRUD routes for every resource on the given HTTP server.
|
|
321
107
|
*
|
|
322
108
|
* Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
|
|
323
109
|
*
|
|
324
|
-
* @param server - The HTTP server adapter to register routes on
|
|
110
|
+
* @param server - The HTTP server adapter to register routes on.
|
|
325
111
|
* @param resources - Resource definitions to wire up as CRUD endpoints.
|
|
326
|
-
* @param options - Auth strategy and
|
|
112
|
+
* @param options - Auth strategy, tenant config, envelope, caching, and OpenAPI overrides.
|
|
327
113
|
*/
|
|
328
114
|
export function registerCrudApi(server, resources, options = {}) {
|
|
329
115
|
const authStrategy = options.authStrategy ?? new AllowAllAuthStrategy();
|
|
@@ -336,15 +122,12 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
336
122
|
const repository = rawResource.repository;
|
|
337
123
|
if (!repository)
|
|
338
124
|
throw new ServerError(`Resource '${rawResource.name ?? rawResource.routePrefix}' does not define a repository.`);
|
|
339
|
-
// Resolve name + field/relation schema once
|
|
340
|
-
//
|
|
341
|
-
// 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.
|
|
342
127
|
const resource = normalizeResource(rawResource);
|
|
343
|
-
//
|
|
344
|
-
// API-wide default — including an explicit `null`/`''`, which opts this resource out.
|
|
128
|
+
// Per-resource envelope wins over API-wide default, including an explicit null/''.
|
|
345
129
|
const envelope = normalizeEnvelope(resource.envelope !== undefined ? resource.envelope : options.envelope);
|
|
346
|
-
// Resolve tenancy once at registration and fail closed on misconfiguration
|
|
347
|
-
// scoped resource can never be silently served unscoped at request time.
|
|
130
|
+
// Resolve tenancy once at registration and fail closed on misconfiguration.
|
|
348
131
|
const tenantField = effectiveTenantField(resource, options.tenant);
|
|
349
132
|
if (tenantField) {
|
|
350
133
|
if (!safeIdentifier.test(tenantField))
|
|
@@ -353,21 +136,12 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
353
136
|
throw new ServerError(`Resource '${resource.name}' is tenant-scoped on '${tenantField}' but its repository ` +
|
|
354
137
|
`does not implement withScope(). Refusing to serve it unscoped.`);
|
|
355
138
|
}
|
|
356
|
-
// Effective cache TTL:
|
|
357
|
-
//
|
|
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).
|
|
358
141
|
const cacheTtl = resource.cache === false
|
|
359
142
|
? undefined
|
|
360
143
|
: (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
|
|
361
144
|
const cachingEnabled = cacheTtl !== undefined;
|
|
362
|
-
/**
|
|
363
|
-
* Wraps a repository in the read-through cache when caching is enabled for this resource.
|
|
364
|
-
* The namespace embeds the resource name and tenant key, so one tenant can never read
|
|
365
|
-
* another's cached rows. `bust` (from the cache-bust header) force-refreshes this request.
|
|
366
|
-
* @param repo - The (possibly tenant-scoped) repository for this request.
|
|
367
|
-
* @param scopeKey - Stable key for the current tenant scope (or `'global'`).
|
|
368
|
-
* @param bust - Whether the caller requested a cache-busting refresh.
|
|
369
|
-
* @returns The repository, cache-wrapped when caching is enabled.
|
|
370
|
-
*/
|
|
371
145
|
const withCache = (repo, scopeKey, bust) => cachingEnabled
|
|
372
146
|
? createCachingRepository(repo, {
|
|
373
147
|
store: cacheStore,
|
|
@@ -376,12 +150,6 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
376
150
|
bust
|
|
377
151
|
})
|
|
378
152
|
: repo;
|
|
379
|
-
/**
|
|
380
|
-
* Returns the repository to use for this request: the tenant-scoped clone when the
|
|
381
|
-
* resource is scoped, or the bare repository otherwise — wrapped in the read-through
|
|
382
|
-
* cache when enabled. Throws 403 in strict mode (the default) when a scoped resource
|
|
383
|
-
* cannot resolve a tenant for the caller.
|
|
384
|
-
*/
|
|
385
153
|
const resolveRepo = async (req, auth) => {
|
|
386
154
|
const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
|
|
387
155
|
if (!tenantField || !options.tenant)
|
|
@@ -396,127 +164,27 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
396
164
|
};
|
|
397
165
|
const permissions = { ...defaultCrudPermissions, ...resource.permissions };
|
|
398
166
|
const basePath = `/${resource.routePrefix}`;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const results = await repo.getMany(listOptions);
|
|
421
|
-
await writeSuccess(res, 200, results, envelope);
|
|
422
|
-
}));
|
|
423
|
-
}
|
|
424
|
-
if (permissions.allowReadManyWithQueryBuilder) {
|
|
425
|
-
server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
|
|
426
|
-
const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
|
|
427
|
-
const repo = await resolveRepo(req, auth);
|
|
428
|
-
if (!repo.executeQuery)
|
|
429
|
-
throw new NotImplementedError('This resource does not support the query builder.');
|
|
430
|
-
const body = (req.body ?? {});
|
|
431
|
-
const query = { ...body };
|
|
432
|
-
validateAdvancedQuery(resource, query);
|
|
433
|
-
const results = await repo.executeQuery(query);
|
|
434
|
-
await writeSuccess(res, 200, results, envelope);
|
|
435
|
-
}));
|
|
436
|
-
}
|
|
437
|
-
if (permissions.allowReadOne) {
|
|
438
|
-
server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
|
|
439
|
-
const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
|
|
440
|
-
const repo = await resolveRepo(req, auth);
|
|
441
|
-
const id = parseId(req.params['id']);
|
|
442
|
-
const listOptions = parseListOptions(req.query, resource);
|
|
443
|
-
const result = await repo.getOne(id, {
|
|
444
|
-
fields: listOptions.fields,
|
|
445
|
-
include: listOptions.include
|
|
446
|
-
});
|
|
447
|
-
if (!result)
|
|
448
|
-
throw new NotFoundError();
|
|
449
|
-
await writeSuccess(res, 200, result, envelope);
|
|
450
|
-
}));
|
|
451
|
-
}
|
|
452
|
-
if (permissions.allowUpdateOne) {
|
|
453
|
-
server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
|
|
454
|
-
const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
|
|
455
|
-
const repo = await resolveRepo(req, auth);
|
|
456
|
-
const id = parseId(req.params['id']);
|
|
457
|
-
const body = filterWritableFields(resource, req.body);
|
|
458
|
-
const result = await repo.updateOne(id, body);
|
|
459
|
-
if (!result)
|
|
460
|
-
throw new NotFoundError();
|
|
461
|
-
await writeSuccess(res, 200, result, envelope);
|
|
462
|
-
}));
|
|
463
|
-
}
|
|
464
|
-
if (permissions.allowUpdateMany) {
|
|
465
|
-
server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
|
|
466
|
-
const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
|
|
467
|
-
const repo = await resolveRepo(req, auth);
|
|
468
|
-
if (!repo.updateMany)
|
|
469
|
-
throw new NotImplementedError('This resource does not support updateMany.');
|
|
470
|
-
const { update, ...queryBody } = (req.body ?? {});
|
|
471
|
-
const filteredUpdate = filterWritableFields(resource, (update ?? {}));
|
|
472
|
-
if (!Object.keys(filteredUpdate).length)
|
|
473
|
-
throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
|
|
474
|
-
const query = { ...queryBody };
|
|
475
|
-
validateAdvancedQuery(resource, query);
|
|
476
|
-
if (!query.where?.length)
|
|
477
|
-
throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
|
|
478
|
-
const result = await repo.updateMany(query, filteredUpdate);
|
|
479
|
-
await writeSuccess(res, 200, result, envelope);
|
|
480
|
-
}));
|
|
481
|
-
}
|
|
482
|
-
if (permissions.allowUpsertOne) {
|
|
483
|
-
server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
|
|
484
|
-
const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
|
|
485
|
-
const repo = await resolveRepo(req, auth);
|
|
486
|
-
if (!repo.upsertOne)
|
|
487
|
-
throw new NotImplementedError('This resource does not support upsert.');
|
|
488
|
-
const id = parseId(req.params['id']);
|
|
489
|
-
const body = filterWritableFields(resource, req.body);
|
|
490
|
-
const result = await repo.upsertOne(id, body);
|
|
491
|
-
await writeSuccess(res, 200, result, envelope);
|
|
492
|
-
}));
|
|
493
|
-
}
|
|
494
|
-
if (permissions.allowDeleteOne) {
|
|
495
|
-
server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
|
|
496
|
-
const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
|
|
497
|
-
const repo = await resolveRepo(req, auth);
|
|
498
|
-
const id = parseId(req.params['id']);
|
|
499
|
-
const deleted = await repo.deleteOne(id);
|
|
500
|
-
if (!deleted)
|
|
501
|
-
throw new NotFoundError();
|
|
502
|
-
await writeSuccess(res, 200, { deleted: true }, envelope);
|
|
503
|
-
}));
|
|
504
|
-
}
|
|
505
|
-
if (permissions.allowDeleteMany) {
|
|
506
|
-
server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
|
|
507
|
-
const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
|
|
508
|
-
const repo = await resolveRepo(req, auth);
|
|
509
|
-
if (!repo.deleteMany)
|
|
510
|
-
throw new NotImplementedError('This resource does not support deleteMany.');
|
|
511
|
-
const body = (req.body ?? {});
|
|
512
|
-
const query = { ...body };
|
|
513
|
-
validateAdvancedQuery(resource, query);
|
|
514
|
-
if (!query.where?.length)
|
|
515
|
-
throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
|
|
516
|
-
const result = await repo.deleteMany(query);
|
|
517
|
-
await writeSuccess(res, 200, result, envelope);
|
|
518
|
-
}));
|
|
519
|
-
}
|
|
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);
|
|
520
188
|
// 405 fallbacks — only registered when at least one method exists for the path
|
|
521
189
|
const baseMethods = [
|
|
522
190
|
...(permissions.allowReadMany ? ['GET'] : []),
|
|
@@ -549,4 +217,41 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
549
217
|
});
|
|
550
218
|
}
|
|
551
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
|
+
}
|
|
552
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
|
+
}
|