@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
@@ -0,0 +1,770 @@
1
+ import { defaultCrudPermissions } from '../core/types.js';
2
+ import { mergeFieldDefinitions } from '../core/fields.js';
3
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
4
+ // Exhaustive map from every FieldType to its JSON Schema type string.
5
+ // Adding a new FieldType causes a compile error here until the map is updated — no switch to edit.
6
+ const FIELD_TYPE_TO_JSON_SCHEMA = {
7
+ string: 'string',
8
+ integer: 'integer',
9
+ number: 'number',
10
+ boolean: 'boolean',
11
+ object: 'object'
12
+ };
13
+ function fieldToSchema(field) {
14
+ const type = field.type ? FIELD_TYPE_TO_JSON_SCHEMA[field.type] : 'string';
15
+ return field.format ? { type, format: field.format } : { type };
16
+ }
17
+ function toPascalCase(routePrefix) {
18
+ return routePrefix
19
+ .split(/[-_/\s]+/)
20
+ .filter(Boolean)
21
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
22
+ .join('');
23
+ }
24
+ function normalizeEnvelope(value) {
25
+ return typeof value === 'string' && value.length > 0 ? value : null;
26
+ }
27
+ function mergeFields(resource) {
28
+ const idField = resource.repository?.idField ?? 'id';
29
+ return mergeFieldDefinitions(resource).map((f) => ({
30
+ ...f,
31
+ writable: f.name === idField ? f.writable === true : f.writable !== false
32
+ }));
33
+ }
34
+ function mergeRelations(resource) {
35
+ const byName = new Map();
36
+ for (const r of resource.repository?.relations ?? [])
37
+ byName.set(r.name, r);
38
+ for (const r of resource.relations ?? [])
39
+ byName.set(r.name, r);
40
+ return [...byName.values()];
41
+ }
42
+ // Wraps a schema under an envelope key if one is active.
43
+ function withEnvelope(schema, envelope) {
44
+ if (!envelope)
45
+ return schema;
46
+ return {
47
+ type: 'object',
48
+ required: [envelope],
49
+ properties: { [envelope]: schema }
50
+ };
51
+ }
52
+ function schemeToObject(scheme) {
53
+ if (scheme.type === 'apiKey')
54
+ return {
55
+ type: 'apiKey',
56
+ in: scheme.in,
57
+ name: scheme.name,
58
+ ...(scheme.description ? { description: scheme.description } : {})
59
+ };
60
+ if (scheme.scheme === 'bearer')
61
+ return {
62
+ type: 'http',
63
+ scheme: 'bearer',
64
+ ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}),
65
+ ...(scheme.description ? { description: scheme.description } : {})
66
+ };
67
+ return {
68
+ type: 'http',
69
+ scheme: scheme.scheme,
70
+ ...(scheme.description ? { description: scheme.description } : {})
71
+ };
72
+ }
73
+ function schemeName(scheme) {
74
+ if (scheme.type === 'apiKey')
75
+ return scheme.in === 'cookie' ? 'SessionAuth' : 'ApiKeyAuth';
76
+ if (scheme.scheme === 'bearer')
77
+ return 'BearerAuth';
78
+ return 'BasicAuth';
79
+ }
80
+ // ─── Shared request headers ───────────────────────────────────────────────────
81
+ const correlationIdHeader = {
82
+ name: 'X-Correlation-ID',
83
+ in: 'header',
84
+ description: 'Optional correlation ID echoed back in the response header for request tracing.',
85
+ schema: { type: 'string' }
86
+ };
87
+ // ─── Shared error/response schemas ───────────────────────────────────────────
88
+ const sharedSchemas = {
89
+ ErrorDetail: {
90
+ type: 'object',
91
+ required: ['code', 'message'],
92
+ properties: {
93
+ code: {
94
+ type: 'string',
95
+ description: 'Machine-readable error code (e.g. `NOT_FOUND`, `UNAUTHORIZED`).',
96
+ example: 'NOT_FOUND'
97
+ },
98
+ message: {
99
+ type: 'string',
100
+ description: 'Human-readable error description.',
101
+ example: 'Not found.'
102
+ },
103
+ details: { description: 'Additional structured detail when available.' }
104
+ }
105
+ },
106
+ ErrorResponse: {
107
+ type: 'object',
108
+ required: ['errors'],
109
+ properties: {
110
+ errors: {
111
+ type: 'array',
112
+ items: { $ref: '#/components/schemas/ErrorDetail' }
113
+ }
114
+ }
115
+ },
116
+ QueryFilter: {
117
+ type: 'object',
118
+ description: [
119
+ 'A single filter condition or a group of nested conditions used in the query builder.',
120
+ '',
121
+ '**Leaf node** — filter a single field:',
122
+ '```json',
123
+ '{ "field": "status", "comparison": "=", "value": "active" }',
124
+ '```',
125
+ '',
126
+ '**Multi-value** — IN / NOT IN:',
127
+ '```json',
128
+ '{ "field": "role", "comparison": "IN", "value": ["admin", "editor"] }',
129
+ '```',
130
+ '',
131
+ '**Range** — BETWEEN:',
132
+ '```json',
133
+ '{ "field": "age", "comparison": "BETWEEN", "value": [18, 65] }',
134
+ '```',
135
+ '',
136
+ '**Group node** — combine children with AND / OR:',
137
+ '```json',
138
+ '{ "operator": "OR", "children": [',
139
+ ' { "field": "role", "comparison": "=", "value": "admin" },',
140
+ ' { "field": "role", "comparison": "=", "value": "editor" }',
141
+ ']}',
142
+ '```'
143
+ ].join('\n'),
144
+ properties: {
145
+ field: { type: 'string', description: 'Field name to filter on (leaf nodes only).' },
146
+ comparison: {
147
+ type: 'string',
148
+ description: 'Comparison operator.',
149
+ enum: [
150
+ '=',
151
+ '<>',
152
+ '<',
153
+ '>',
154
+ '<=',
155
+ '>=',
156
+ 'IN',
157
+ 'NOT IN',
158
+ 'BETWEEN',
159
+ 'NOT BETWEEN',
160
+ 'LIKE',
161
+ 'NOT LIKE',
162
+ 'IS NULL',
163
+ 'IS NOT NULL',
164
+ 'CONTAINS',
165
+ 'STARTS WITH',
166
+ 'ENDS WITH'
167
+ ]
168
+ },
169
+ value: {
170
+ description: 'Scalar value, or array of values for `IN`/`NOT IN`/`BETWEEN`/`NOT BETWEEN`. Omit for `IS NULL` / `IS NOT NULL`.',
171
+ anyOf: [
172
+ { type: 'string' },
173
+ { type: 'number' },
174
+ { type: 'boolean' },
175
+ { type: 'array', items: {} }
176
+ ]
177
+ },
178
+ operator: {
179
+ type: 'string',
180
+ enum: ['AND', 'OR'],
181
+ description: 'Logical combinator used when `children` is present. Defaults to `AND`.'
182
+ },
183
+ children: {
184
+ type: 'array',
185
+ items: { $ref: '#/components/schemas/QueryFilter' },
186
+ description: 'Nested filter conditions combined with `operator`.'
187
+ }
188
+ }
189
+ },
190
+ QueryOptions: {
191
+ type: 'object',
192
+ description: [
193
+ 'Request body for `POST .../query`. All fields are optional.',
194
+ '',
195
+ 'The `where` array is evaluated left-to-right with AND precedence over OR — the same',
196
+ 'rules as SQL. Use nested `children` groups for complex parenthesised expressions.',
197
+ '',
198
+ '**Example — paginated, filtered, sorted query:**',
199
+ '```json',
200
+ '{',
201
+ ' "where": [',
202
+ ' { "field": "published", "comparison": "=", "value": true },',
203
+ ' { "field": "createdAt", "comparison": ">=", "value": "2024-01-01T00:00:00Z" }',
204
+ ' ],',
205
+ ' "orderBy": [{ "field": "createdAt", "direction": "desc" }],',
206
+ ' "limit": 20,',
207
+ ' "offset": 0,',
208
+ ' "fields": ["id", "title", "createdAt"],',
209
+ ' "include": ["author"]',
210
+ '}',
211
+ '```'
212
+ ].join('\n'),
213
+ properties: {
214
+ where: {
215
+ type: 'array',
216
+ items: { $ref: '#/components/schemas/QueryFilter' },
217
+ description: 'Filter conditions. AND-precedence over OR within the flat list.'
218
+ },
219
+ limit: { type: 'integer', minimum: 0, description: 'Maximum records to return.' },
220
+ offset: { type: 'integer', minimum: 0, default: 0, description: 'Records to skip.' },
221
+ fields: {
222
+ type: 'array',
223
+ items: { type: 'string' },
224
+ description: 'Field names to include in each record. Omit to return all selectable fields.'
225
+ },
226
+ orderBy: {
227
+ type: 'array',
228
+ description: 'Sort order. Multiple entries produce multi-column sorting.',
229
+ items: {
230
+ type: 'object',
231
+ required: ['field', 'direction'],
232
+ properties: {
233
+ field: { type: 'string', description: 'Field name to sort by (must be sortable).' },
234
+ direction: { type: 'string', enum: ['asc', 'desc'] }
235
+ }
236
+ }
237
+ },
238
+ include: {
239
+ type: 'array',
240
+ items: { type: 'string' },
241
+ description: 'Relation names to eagerly load (if the resource supports includes).'
242
+ },
243
+ distinct: { type: 'boolean', description: 'When `true`, de-duplicates results.' }
244
+ }
245
+ }
246
+ };
247
+ // ─── Shared error responses ───────────────────────────────────────────────────
248
+ const errorRef = (description) => ({
249
+ description,
250
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
251
+ });
252
+ const commonErrors = {
253
+ '401': errorRef('Unauthorized — missing or invalid credentials.'),
254
+ '403': errorRef('Forbidden — valid credentials but insufficient permissions.'),
255
+ '406': errorRef('Not Acceptable — client does not accept `application/json`.'),
256
+ '500': errorRef('Internal Server Error.')
257
+ };
258
+ const writeErrors = {
259
+ '415': errorRef('Unsupported Media Type — body must be `application/json`.')
260
+ };
261
+ const badRequestError = errorRef('Bad Request — malformed query string, invalid ID, or invalid request body.');
262
+ const notFoundError = errorRef('Not Found — the record with the given ID does not exist.');
263
+ const unprocessableError = errorRef('Unprocessable Entity — request body contains unknown or non-writable fields.');
264
+ const notImplementedError = errorRef('Not Implemented — the underlying repository does not support this operation.');
265
+ // ─── Main export ──────────────────────────────────────────────────────────────
266
+ export function generateOpenApiSpec(resources, options = {}) {
267
+ const globalEnvelope = normalizeEnvelope(options.envelope);
268
+ const scheme = options.securityScheme;
269
+ const securityName = scheme ? schemeName(scheme) : undefined;
270
+ const spec = {
271
+ openapi: '3.1.0',
272
+ info: {
273
+ title: options.title ?? 'Halifax API',
274
+ version: options.version ?? '1.0.0',
275
+ ...(options.description ? { description: options.description } : {})
276
+ },
277
+ ...(options.servers?.length ? { servers: options.servers } : {}),
278
+ ...(securityName ? { security: [{ [securityName]: [] }] } : {}),
279
+ paths: {},
280
+ components: {
281
+ schemas: { ...sharedSchemas },
282
+ ...(scheme && securityName
283
+ ? { securitySchemes: { [securityName]: schemeToObject(scheme) } }
284
+ : {})
285
+ }
286
+ };
287
+ for (const resource of resources) {
288
+ const permissions = { ...defaultCrudPermissions, ...resource.permissions };
289
+ const fields = mergeFields(resource);
290
+ const relations = mergeRelations(resource);
291
+ const idField = resource.repository?.idField ?? 'id';
292
+ const schemaBase = toPascalCase(resource.routePrefix);
293
+ const tag = resource.name ?? schemaBase;
294
+ const basePath = `/${resource.routePrefix}`;
295
+ // Resolve envelope: per-resource wins over API-wide default.
296
+ const envelope = resource.envelope !== undefined ? normalizeEnvelope(resource.envelope) : globalEnvelope;
297
+ // ─── Component schemas ───────────────────────────────────────────────────
298
+ const selectableFields = fields.filter((f) => f.selectable !== false);
299
+ const readProperties = {};
300
+ for (const f of selectableFields) {
301
+ readProperties[f.name] = {
302
+ ...fieldToSchema(f),
303
+ ...(f.writable === false ? { readOnly: true } : {})
304
+ };
305
+ }
306
+ spec.components.schemas[schemaBase] = { type: 'object', properties: readProperties };
307
+ const writableFields = fields.filter((f) => f.name !== idField && f.writable !== false);
308
+ const writeProperties = Object.fromEntries(writableFields.map((f) => [f.name, fieldToSchema(f)]));
309
+ spec.components.schemas[`${schemaBase}Create`] = { type: 'object', properties: writeProperties };
310
+ spec.components.schemas[`${schemaBase}Update`] = { type: 'object', properties: writeProperties };
311
+ // List response: { count: number, results: TRecord[] } (Halifax's ListResult / QueryResult shape)
312
+ const listResultSchema = {
313
+ type: 'object',
314
+ required: ['count', 'results'],
315
+ properties: {
316
+ count: { type: 'integer', description: 'Total matching records before pagination.' },
317
+ results: { type: 'array', items: { $ref: `#/components/schemas/${schemaBase}` } }
318
+ }
319
+ };
320
+ spec.components.schemas[`${schemaBase}List`] = withEnvelope(listResultSchema, envelope);
321
+ // ─── Shared parameters ───────────────────────────────────────────────────
322
+ const includableRelations = relations.filter((r) => r.includable !== false);
323
+ const includeParam = includableRelations.length > 0
324
+ ? {
325
+ name: 'include',
326
+ in: 'query',
327
+ description: `Comma-separated relation names to eagerly load. Available: \`${includableRelations.map((r) => r.name).join('`, `')}\`.`,
328
+ schema: { type: 'string', enum: includableRelations.map((r) => r.name) }
329
+ }
330
+ : undefined;
331
+ const sortableFieldNames = fields.filter((f) => f.sortable !== false).map((f) => f.name);
332
+ const selectableFieldNames = selectableFields.map((f) => f.name);
333
+ const filterableFields = fields.filter((f) => f.filterable !== false);
334
+ const fieldsParam = {
335
+ name: 'fields',
336
+ in: 'query',
337
+ description: `Comma-separated field names to include in each record. Available: \`${selectableFieldNames.join('`, `')}\`.`,
338
+ schema: { type: 'string' }
339
+ };
340
+ const orderParam = {
341
+ name: 'order',
342
+ in: 'query',
343
+ description: `Sort expression. Format: \`field:asc\` or \`field:desc\`, comma-separated for multiple columns. Sortable fields: \`${sortableFieldNames.join('`, `')}\`.`,
344
+ schema: {
345
+ type: 'string',
346
+ example: sortableFieldNames[0] ? `${sortableFieldNames[0]}:desc` : 'id:desc'
347
+ }
348
+ };
349
+ const filterParams = filterableFields.map((f) => ({
350
+ name: f.name,
351
+ in: 'query',
352
+ description: `Equality filter on \`${f.name}\`. For range / pattern filters use \`POST .../query\`.`,
353
+ schema: fieldToSchema(f)
354
+ }));
355
+ const listQueryParams = [
356
+ {
357
+ name: 'limit',
358
+ in: 'query',
359
+ description: 'Maximum records to return. Defaults to resource limit (up to 5000).',
360
+ schema: { type: 'integer', minimum: 0 }
361
+ },
362
+ {
363
+ name: 'offset',
364
+ in: 'query',
365
+ description: 'Records to skip for pagination. Defaults to `0`.',
366
+ schema: { type: 'integer', minimum: 0, default: 0 }
367
+ },
368
+ fieldsParam,
369
+ orderParam,
370
+ ...(includeParam ? [includeParam] : []),
371
+ ...filterParams,
372
+ correlationIdHeader
373
+ ];
374
+ const idParam = {
375
+ name: 'id',
376
+ in: 'path',
377
+ required: true,
378
+ description: 'Resource identifier (integer, UUID, or ObjectId).',
379
+ schema: { type: 'string' }
380
+ };
381
+ const singleQueryParams = [
382
+ idParam,
383
+ fieldsParam,
384
+ ...(includeParam ? [includeParam] : []),
385
+ correlationIdHeader
386
+ ];
387
+ // ─── GET /resource (readMany) ────────────────────────────────────────────
388
+ if (permissions.allowReadMany) {
389
+ spec.paths[basePath] ??= {};
390
+ spec.paths[basePath].get = {
391
+ operationId: `list${schemaBase}`,
392
+ summary: `List ${tag}`,
393
+ description: [
394
+ `Returns a paginated list of ${tag} records.`,
395
+ '',
396
+ 'Use `?limit` and `?offset` for pagination. Use `?fieldName=value` for simple equality',
397
+ 'filters. For advanced filtering (range, LIKE, IN, nested OR/AND) use',
398
+ `\`POST ${basePath}/query\` instead.`
399
+ ].join('\n'),
400
+ tags: [tag],
401
+ parameters: listQueryParams,
402
+ responses: {
403
+ '200': {
404
+ description: 'OK',
405
+ content: {
406
+ 'application/json': { schema: { $ref: `#/components/schemas/${schemaBase}List` } }
407
+ }
408
+ },
409
+ '400': badRequestError,
410
+ ...commonErrors
411
+ }
412
+ };
413
+ }
414
+ // ─── POST /resource (create) ─────────────────────────────────────────────
415
+ if (permissions.allowCreate) {
416
+ spec.paths[basePath] ??= {};
417
+ const singleResponse = withEnvelope({ $ref: `#/components/schemas/${schemaBase}` }, envelope);
418
+ const arrayResponse = withEnvelope({ type: 'array', items: { $ref: `#/components/schemas/${schemaBase}` } }, envelope);
419
+ spec.paths[basePath].post = {
420
+ operationId: `create${schemaBase}`,
421
+ summary: `Create ${tag}`,
422
+ description: [
423
+ `Creates one or many ${tag} records.`,
424
+ '',
425
+ 'Pass a single object to create one record (returns the created record).',
426
+ 'Pass an array to bulk-create (returns the created records when the repository',
427
+ 'supports it, otherwise an empty array).',
428
+ '',
429
+ '`Idempotency-Key` header is supported: repeated requests with the same key',
430
+ 'are de-duplicated by the repository when it implements idempotency.'
431
+ ].join('\n'),
432
+ tags: [tag],
433
+ parameters: [
434
+ correlationIdHeader,
435
+ {
436
+ name: 'Idempotency-Key',
437
+ in: 'header',
438
+ description: 'Optional idempotency key. Duplicate requests with the same key return the original response.',
439
+ schema: { type: 'string' }
440
+ }
441
+ ],
442
+ requestBody: {
443
+ required: true,
444
+ content: {
445
+ 'application/json': {
446
+ schema: {
447
+ oneOf: [
448
+ { $ref: `#/components/schemas/${schemaBase}Create` },
449
+ { type: 'array', items: { $ref: `#/components/schemas/${schemaBase}Create` } }
450
+ ]
451
+ }
452
+ }
453
+ }
454
+ },
455
+ responses: {
456
+ '201': {
457
+ description: 'Created',
458
+ content: { 'application/json': { schema: { oneOf: [singleResponse, arrayResponse] } } }
459
+ },
460
+ '400': badRequestError,
461
+ '422': unprocessableError,
462
+ ...commonErrors,
463
+ ...writeErrors
464
+ }
465
+ };
466
+ }
467
+ // ─── PATCH /resource (updateMany) ────────────────────────────────────────
468
+ if (permissions.allowUpdateMany) {
469
+ spec.paths[basePath] ??= {};
470
+ const updateManyBodySchema = {
471
+ type: 'object',
472
+ required: ['update'],
473
+ description: [
474
+ 'Combines a query (to select which records to update) with the update payload.',
475
+ '',
476
+ 'The `update` field contains the fields to set. All other top-level fields are',
477
+ 'interpreted as `QueryOptions` — use `where` (required) to target records.',
478
+ '',
479
+ '**Example:**',
480
+ '```json',
481
+ '{',
482
+ ' "where": [{ "field": "status", "comparison": "=", "value": "draft" }],',
483
+ ' "update": { "status": "archived" }',
484
+ '}',
485
+ '```'
486
+ ].join('\n'),
487
+ properties: {
488
+ update: {
489
+ $ref: `#/components/schemas/${schemaBase}Update`,
490
+ description: 'Fields to apply to every matched record. At least one writable field required.'
491
+ },
492
+ where: {
493
+ type: 'array',
494
+ items: { $ref: '#/components/schemas/QueryFilter' },
495
+ description: '**Required.** At least one filter is mandatory to prevent unintended full-table updates.'
496
+ },
497
+ limit: { type: 'integer', minimum: 0, description: 'Maximum records to update.' },
498
+ offset: { type: 'integer', minimum: 0, description: 'Records to skip before updating.' },
499
+ orderBy: {
500
+ type: 'array',
501
+ items: {
502
+ type: 'object',
503
+ required: ['field', 'direction'],
504
+ properties: {
505
+ field: { type: 'string' },
506
+ direction: { type: 'string', enum: ['asc', 'desc'] }
507
+ }
508
+ }
509
+ }
510
+ }
511
+ };
512
+ const updateManyResponseBody = {
513
+ type: 'object',
514
+ required: ['updated'],
515
+ properties: {
516
+ updated: { type: 'array', items: {}, description: 'IDs of updated records.' },
517
+ results: {
518
+ type: 'array',
519
+ items: { $ref: `#/components/schemas/${schemaBase}` },
520
+ description: 'Updated records (when the repository supports returning them).'
521
+ }
522
+ }
523
+ };
524
+ spec.paths[basePath].patch = {
525
+ operationId: `updateMany${schemaBase}`,
526
+ summary: `Bulk-update ${tag}`,
527
+ tags: [tag],
528
+ parameters: [correlationIdHeader],
529
+ requestBody: {
530
+ required: true,
531
+ content: { 'application/json': { schema: updateManyBodySchema } }
532
+ },
533
+ responses: {
534
+ '200': {
535
+ description: 'OK',
536
+ content: {
537
+ 'application/json': { schema: withEnvelope(updateManyResponseBody, envelope) }
538
+ }
539
+ },
540
+ '400': badRequestError,
541
+ '422': unprocessableError,
542
+ '501': notImplementedError,
543
+ ...commonErrors,
544
+ ...writeErrors
545
+ }
546
+ };
547
+ }
548
+ // ─── DELETE /resource (deleteMany) ───────────────────────────────────────
549
+ if (permissions.allowDeleteMany) {
550
+ spec.paths[basePath] ??= {};
551
+ const deleteManyBodySchema = {
552
+ allOf: [{ $ref: '#/components/schemas/QueryOptions' }],
553
+ description: [
554
+ 'Full `QueryOptions` body. `where` is **required** — at least one filter',
555
+ 'must be present to prevent unintended full-table deletes.',
556
+ '',
557
+ '**Example:**',
558
+ '```json',
559
+ '{',
560
+ ' "where": [{ "field": "deletedAt", "comparison": "IS NOT NULL" }]',
561
+ '}',
562
+ '```'
563
+ ].join('\n')
564
+ };
565
+ const deleteManyResponseBody = {
566
+ type: 'object',
567
+ required: ['deleted'],
568
+ properties: {
569
+ deleted: { type: 'array', items: {}, description: 'IDs or records of the deleted rows.' }
570
+ }
571
+ };
572
+ spec.paths[basePath].delete = {
573
+ operationId: `deleteMany${schemaBase}`,
574
+ summary: `Bulk-delete ${tag}`,
575
+ tags: [tag],
576
+ parameters: [correlationIdHeader],
577
+ requestBody: {
578
+ required: true,
579
+ content: { 'application/json': { schema: deleteManyBodySchema } }
580
+ },
581
+ responses: {
582
+ '200': {
583
+ description: 'OK',
584
+ content: {
585
+ 'application/json': { schema: withEnvelope(deleteManyResponseBody, envelope) }
586
+ }
587
+ },
588
+ '400': badRequestError,
589
+ '422': unprocessableError,
590
+ '501': notImplementedError,
591
+ ...commonErrors,
592
+ ...writeErrors
593
+ }
594
+ };
595
+ }
596
+ // ─── POST /resource/query (readManyWithQueryBuilder) ─────────────────────
597
+ if (permissions.allowReadManyWithQueryBuilder) {
598
+ const queryPath = `${basePath}/query`;
599
+ spec.paths[queryPath] ??= {};
600
+ spec.paths[queryPath].post = {
601
+ operationId: `query${schemaBase}`,
602
+ summary: `Query ${tag}`,
603
+ description: [
604
+ `Advanced endpoint for filtering, sorting, and paginating ${tag} records.`,
605
+ '',
606
+ 'Accepts a full `QueryOptions` body — all fields are optional, so an empty body',
607
+ '`{}` behaves identically to `GET /<resource>`. Only use this endpoint when you need',
608
+ 'operators beyond simple equality (e.g. `>=`, `LIKE`, `IN`, nested OR groups).',
609
+ '',
610
+ '### Supported comparison operators',
611
+ '',
612
+ '| Operator | Meaning |',
613
+ '|---|---|',
614
+ '| `=` | Equals |',
615
+ '| `<>` | Not equals |',
616
+ '| `<` `>` `<=` `>=` | Numeric / date comparison |',
617
+ '| `IN` / `NOT IN` | Membership test (pass array as value) |',
618
+ '| `BETWEEN` / `NOT BETWEEN` | Range test (pass `[min, max]` as value) |',
619
+ '| `LIKE` / `NOT LIKE` | SQL LIKE pattern (`%` wildcard) |',
620
+ '| `CONTAINS` | Substring match |',
621
+ '| `STARTS WITH` | Prefix match |',
622
+ '| `ENDS WITH` | Suffix match |',
623
+ '| `IS NULL` / `IS NOT NULL` | Null check (no value needed) |',
624
+ '',
625
+ '### AND / OR precedence',
626
+ '',
627
+ 'Flat `where` arrays use **AND-precedence over OR** (same as SQL).',
628
+ 'Use nested `children` groups for explicit parenthesisation.'
629
+ ].join('\n'),
630
+ tags: [tag],
631
+ parameters: [correlationIdHeader],
632
+ requestBody: {
633
+ required: false,
634
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/QueryOptions' } } }
635
+ },
636
+ responses: {
637
+ '200': {
638
+ description: 'OK',
639
+ content: {
640
+ 'application/json': { schema: { $ref: `#/components/schemas/${schemaBase}List` } }
641
+ }
642
+ },
643
+ '400': badRequestError,
644
+ '501': notImplementedError,
645
+ ...commonErrors,
646
+ ...writeErrors
647
+ }
648
+ };
649
+ }
650
+ // ─── GET /resource/{id} (readOne) ────────────────────────────────────────
651
+ const itemPath = `${basePath}/{id}`;
652
+ if (permissions.allowReadOne) {
653
+ spec.paths[itemPath] ??= {};
654
+ spec.paths[itemPath].get = {
655
+ operationId: `get${schemaBase}`,
656
+ summary: `Get ${tag} by ID`,
657
+ tags: [tag],
658
+ parameters: singleQueryParams,
659
+ responses: {
660
+ '200': {
661
+ description: 'OK',
662
+ content: {
663
+ 'application/json': {
664
+ schema: withEnvelope({ $ref: `#/components/schemas/${schemaBase}` }, envelope)
665
+ }
666
+ }
667
+ },
668
+ '400': badRequestError,
669
+ '404': notFoundError,
670
+ ...commonErrors
671
+ }
672
+ };
673
+ }
674
+ // ─── PATCH /resource/{id} (updateOne) ────────────────────────────────────
675
+ if (permissions.allowUpdateOne) {
676
+ spec.paths[itemPath] ??= {};
677
+ spec.paths[itemPath].patch = {
678
+ operationId: `update${schemaBase}`,
679
+ summary: `Update ${tag}`,
680
+ description: `Partially updates a ${tag} record. Only the fields present in the body are changed.`,
681
+ tags: [tag],
682
+ parameters: [idParam, correlationIdHeader],
683
+ requestBody: {
684
+ required: true,
685
+ content: {
686
+ 'application/json': { schema: { $ref: `#/components/schemas/${schemaBase}Update` } }
687
+ }
688
+ },
689
+ responses: {
690
+ '200': {
691
+ description: 'OK',
692
+ content: {
693
+ 'application/json': {
694
+ schema: withEnvelope({ $ref: `#/components/schemas/${schemaBase}` }, envelope)
695
+ }
696
+ }
697
+ },
698
+ '400': badRequestError,
699
+ '404': notFoundError,
700
+ '422': unprocessableError,
701
+ ...commonErrors,
702
+ ...writeErrors
703
+ }
704
+ };
705
+ }
706
+ // ─── PUT /resource/{id} (upsertOne) ──────────────────────────────────────
707
+ if (permissions.allowUpsertOne) {
708
+ spec.paths[itemPath] ??= {};
709
+ spec.paths[itemPath].put = {
710
+ operationId: `upsert${schemaBase}`,
711
+ summary: `Upsert ${tag}`,
712
+ description: [
713
+ `Creates or replaces a ${tag} record at the given ID.`,
714
+ '',
715
+ 'If a record with the given `id` already exists it is replaced; otherwise a new',
716
+ 'record is created. Always returns the resulting record with HTTP `200`.'
717
+ ].join('\n'),
718
+ tags: [tag],
719
+ parameters: [idParam, correlationIdHeader],
720
+ requestBody: {
721
+ required: true,
722
+ content: {
723
+ 'application/json': { schema: { $ref: `#/components/schemas/${schemaBase}Create` } }
724
+ }
725
+ },
726
+ responses: {
727
+ '200': {
728
+ description: 'OK — record created or replaced.',
729
+ content: {
730
+ 'application/json': {
731
+ schema: withEnvelope({ $ref: `#/components/schemas/${schemaBase}` }, envelope)
732
+ }
733
+ }
734
+ },
735
+ '400': badRequestError,
736
+ '422': unprocessableError,
737
+ '501': notImplementedError,
738
+ ...commonErrors,
739
+ ...writeErrors
740
+ }
741
+ };
742
+ }
743
+ // ─── DELETE /resource/{id} (deleteOne) ───────────────────────────────────
744
+ if (permissions.allowDeleteOne) {
745
+ spec.paths[itemPath] ??= {};
746
+ const deleteOneResponse = withEnvelope({
747
+ type: 'object',
748
+ required: ['deleted'],
749
+ properties: { deleted: { type: 'boolean', example: true } }
750
+ }, envelope);
751
+ spec.paths[itemPath].delete = {
752
+ operationId: `delete${schemaBase}`,
753
+ summary: `Delete ${tag}`,
754
+ tags: [tag],
755
+ parameters: [idParam, correlationIdHeader],
756
+ responses: {
757
+ '200': {
758
+ description: 'Deleted — returns `{ "deleted": true }`.',
759
+ content: { 'application/json': { schema: deleteOneResponse } }
760
+ },
761
+ '400': badRequestError,
762
+ '404': notFoundError,
763
+ ...commonErrors,
764
+ ...writeErrors
765
+ }
766
+ };
767
+ }
768
+ }
769
+ return spec;
770
+ }