@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +102 -17
  3. package/README_AUTH.md +38 -0
  4. package/README_AUTOCRUD.md +5 -5
  5. package/README_CLASSES.md +322 -0
  6. package/README_HOOKS.md +275 -0
  7. package/README_INTERFACES.md +601 -0
  8. package/README_OPENAPI.md +471 -0
  9. package/README_REPO_ADAPTERS.md +77 -0
  10. package/README_TYPES.md +114 -0
  11. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +128 -0
  12. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +255 -0
  13. package/dist/adapters/orm/drizzle/astToDrizzle.d.ts +21 -0
  14. package/dist/adapters/orm/drizzle/astToDrizzle.js +121 -0
  15. package/dist/adapters/orm/drizzle/index.d.ts +4 -0
  16. package/dist/adapters/orm/drizzle/index.js +2 -0
  17. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -1
  18. package/dist/adapters/orm/prisma/PrismaAdapter.js +24 -1
  19. package/dist/adapters/orm/prisma/astToPrisma.d.ts +1 -2
  20. package/dist/adapters/orm/prisma/astToPrisma.js +1 -3
  21. package/dist/adapters/orm/prisma/helpers.js +1 -1
  22. package/dist/adapters/orm/prisma/types.d.ts +11 -11
  23. package/dist/auth/AuthStrategy.d.ts +6 -189
  24. package/dist/auth/AuthStrategy.js +4 -220
  25. package/dist/auth/strategies/AllowAllAuthStrategy.d.ts +6 -0
  26. package/dist/auth/strategies/AllowAllAuthStrategy.js +6 -0
  27. package/dist/auth/strategies/ApiKeyAuthStrategy.d.ts +25 -0
  28. package/dist/auth/strategies/ApiKeyAuthStrategy.js +39 -0
  29. package/dist/auth/strategies/JwtClaimsAuthStrategy.d.ts +32 -0
  30. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +52 -0
  31. package/dist/auth/strategies/PassportStrategies.d.ts +94 -0
  32. package/dist/auth/strategies/PassportStrategies.js +142 -0
  33. package/dist/auth/strategies/types.d.ts +70 -0
  34. package/dist/core/crudRouter.d.ts +11 -18
  35. package/dist/core/crudRouter.js +95 -390
  36. package/dist/core/fields.d.ts +8 -0
  37. package/dist/core/fields.js +14 -0
  38. package/dist/core/handlerUtils.d.ts +70 -0
  39. package/dist/core/handlerUtils.js +193 -0
  40. package/dist/core/handlers/create.d.ts +3 -0
  41. package/dist/core/handlers/create.js +26 -0
  42. package/dist/core/handlers/deleteMany.d.ts +3 -0
  43. package/dist/core/handlers/deleteMany.js +24 -0
  44. package/dist/core/handlers/deleteOne.d.ts +3 -0
  45. package/dist/core/handlers/deleteOne.js +19 -0
  46. package/dist/core/handlers/query.d.ts +3 -0
  47. package/dist/core/handlers/query.js +23 -0
  48. package/dist/core/handlers/readMany.d.ts +3 -0
  49. package/dist/core/handlers/readMany.js +18 -0
  50. package/dist/core/handlers/readOne.d.ts +3 -0
  51. package/dist/core/handlers/readOne.js +23 -0
  52. package/dist/core/handlers/updateMany.d.ts +3 -0
  53. package/dist/core/handlers/updateMany.js +34 -0
  54. package/dist/core/handlers/updateOne.d.ts +3 -0
  55. package/dist/core/handlers/updateOne.js +20 -0
  56. package/dist/core/handlers/upsertOne.d.ts +3 -0
  57. package/dist/core/handlers/upsertOne.js +20 -0
  58. package/dist/core/hooks.d.ts +217 -0
  59. package/dist/core/queryString.js +1 -1
  60. package/dist/core/types.d.ts +38 -29
  61. package/dist/core/validation.d.ts +1 -2
  62. package/dist/core/validation.js +1 -3
  63. package/dist/index.d.ts +3 -6
  64. package/dist/index.js +3 -6
  65. package/dist/openapi/generateDocsHtml.d.ts +1 -0
  66. package/dist/openapi/generateDocsHtml.js +47 -0
  67. package/dist/openapi/index.d.ts +3 -0
  68. package/dist/openapi/index.js +2 -0
  69. package/dist/openapi/specGenerator.d.ts +149 -0
  70. package/dist/openapi/specGenerator.js +770 -0
  71. package/package.json +38 -22
  72. package/dist/enums/SqlComparison.d.ts +0 -28
  73. package/dist/enums/SqlComparison.js +0 -29
  74. package/dist/enums/SqlOperator.d.ts +0 -5
  75. package/dist/enums/SqlOperator.js +0 -6
  76. package/dist/enums/SqlOrder.d.ts +0 -5
  77. package/dist/enums/SqlOrder.js +0 -6
  78. package/dist/interfaces/IQueryFilter.d.ts +0 -17
  79. package/dist/interfaces/IQueryOptions.d.ts +0 -20
  80. package/dist/interfaces/ISort.d.ts +0 -8
  81. package/dist/interfaces/ISort.js +0 -1
  82. /package/dist/{interfaces/IQueryFilter.js → auth/strategies/types.js} +0 -0
  83. /package/dist/{interfaces/IQueryOptions.js → core/hooks.js} +0 -0
@@ -1,57 +1,25 @@
1
1
  import { AllowAllAuthStrategy } from '../auth/AuthStrategy.js';
2
2
  import { createCachingRepository, InMemoryCacheStore } from '../core/cache/index.js';
3
- import { HttpError } from '../errors/HttpError.js';
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
- * Parses and validates a raw `:id` route parameter.
17
- * @param raw - The raw string value from `req.params.id`.
18
- * @returns A parsed integer for numeric IDs, or the original string for UUID / ObjectId keys.
19
- * @throws {@link BadRequestError} when the value is not a valid integer, UUID, or ObjectId.
20
- */
21
- function parseId(raw) {
22
- validateId(raw);
23
- // UUID and Mongo ObjectId keys are passed through as strings; only numeric ids are parsed.
24
- if (typeof raw === 'string' && (isValidUuid(raw) || isValidObjectId(raw)))
25
- return raw;
26
- return typeof raw === 'string' ? parseInt(raw, 10) : raw;
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. When the repository exposes a field
65
- * schema it forms the base, and the resource's own `fields` are merged over it **by name**
66
- * as sparse overrides (entries with no matching base field are added). When the repository
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 byName = new Map();
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 (derived from `routePrefix` when
113
- * omitted), and `fields`/`relations` resolved from the repository schema + the resource's
114
- * own (override) entries. Every downstream router stage operates on this normalized form,
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
- * 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
- /**
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
- * Writes a success body as JSON, wrapping it under `envelope` when one is configured.
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 (e.g. {@link ExpressHttpServer}).
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 query-builder path overrides.
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, here, so every downstream stage (validation,
340
- // write-filtering, tenant auto-detection, cache namespace) works off a single source of
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
- // Resolve the response envelope once: an explicit per-resource setting wins over the
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, so a
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: an explicit `cache: false` disables; otherwise per-resource config
357
- // wins over the API-wide default. `0` means "never expire" (so `undefined` = no caching).
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
- if (permissions.allowCreate) {
400
- server.registerRoute('POST', basePath, wrap(async (req, res) => {
401
- const auth = await authorizeRequest(req, resource, 'create', authStrategy);
402
- const repo = await resolveRepo(req, auth);
403
- const idempotencyKey = getHeaderValue(req, 'idempotency-key');
404
- const createOptions = idempotencyKey ? { idempotencyKey } : undefined;
405
- const items = (Array.isArray(req.body) ? req.body : [req.body]).map((item) => filterWritableFields(resource, item));
406
- if (items.length === 1) {
407
- const result = await repo.createOne(items[0], createOptions);
408
- await writeSuccess(res, 201, result, envelope);
409
- return;
410
- }
411
- const results = await repo.createMany(items, createOptions);
412
- await writeSuccess(res, 201, results, envelope);
413
- }));
414
- }
415
- if (permissions.allowReadMany) {
416
- server.registerRoute('GET', basePath, wrap(async (req, res) => {
417
- const auth = await authorizeRequest(req, resource, 'readMany', authStrategy);
418
- const repo = await resolveRepo(req, auth);
419
- const listOptions = parseListOptions(req.query, resource);
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
+ }