@edium/halifax 2.0.0 → 2.1.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 CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [2.1.0]
7
+
8
+ ### Added
9
+
10
+ - **Configurable response envelope.** A new `envelope` option wraps every success response body
11
+ under a single key (e.g. `envelope: 'data'` → `{ "data": <body> }`). Set it API-wide on
12
+ `createExpressCrudRouter`/`registerCrudApi` options, or per resource on `ResourceDefinition`
13
+ (the per-resource setting wins, including an explicit `null`/`''` to opt a single resource out
14
+ of an API-wide envelope). The wrap is uniform across list, single, create/update/upsert, and
15
+ the delete confirmation; **error responses are never enveloped**, keeping one stable error
16
+ contract. Applied at the response boundary (after the cache), so cached payloads are
17
+ envelope-agnostic. Eases adopting Halifax behind clients that expect a legacy `{ data: ... }`
18
+ shape. Defaults to off — fully backward compatible.
19
+
6
20
  ## [2.0.0]
7
21
 
8
22
  A breaking release with two themes: **permissive, minimal-by-default resource definitions**
@@ -141,6 +141,39 @@ always pulled all rows:
141
141
  { defaultLimit: 0, maxLimit: 0 } // no pagination — return everything
142
142
  ```
143
143
 
144
+ ## Response Envelope
145
+
146
+ By default every success response is sent **bare** — a list is `{ count, results }`, a single
147
+ record is the object itself, a delete is `{ deleted: true }`. To wrap every success body under a
148
+ single key (handy when adopting Halifax behind a client that expects a legacy `{ data: ... }`
149
+ shape), set `envelope` — API-wide or per resource:
150
+
151
+ ```ts
152
+ // API-wide: every resource's success body is wrapped under "data"
153
+ createExpressCrudRouter(resources, { authStrategy, envelope: 'data' })
154
+
155
+ // Per resource (wins over the API-wide setting):
156
+ { routePrefix: 'posts', repository, envelope: 'data' }
157
+ ```
158
+
159
+ The wrap is **uniform** — it nests the entire body, it does not reshape it:
160
+
161
+ | Endpoint | Bare (default) | `envelope: 'data'` |
162
+ | --------------- | ------------------------- | ------------------------------------- |
163
+ | List / query | `{ count, results }` | `{ data: { count, results } }` |
164
+ | Read / create | `{ id, ... }` | `{ data: { id, ... } }` |
165
+ | Delete one | `{ deleted: true }` | `{ data: { deleted: true } }` |
166
+
167
+ Notes:
168
+
169
+ - **Errors are never enveloped** — they always use the `{ errors: [...] }` shape (see below), so
170
+ clients have one stable error contract regardless of the success envelope.
171
+ - **Precedence** — an explicit per-resource `envelope` always wins, including `null` or `''`,
172
+ which opts a single resource out of an API-wide envelope. Omit it to inherit the API default.
173
+ - `null`, `undefined`, and `''` all mean "no envelope" (an empty key is rejected as meaningless).
174
+ - The envelope is applied at the response boundary, after the read-through cache, so cached
175
+ payloads are envelope-agnostic.
176
+
144
177
  ## Query-String Filtering and Pagination
145
178
 
146
179
  ```
@@ -48,6 +48,13 @@ export interface CrudApiOptions {
48
48
  tenant?: TenantOptions;
49
49
  /** Path segment for the query-builder POST route (default: `'query'`). */
50
50
  queryBuilderPath?: string;
51
+ /**
52
+ * Wrap every success response body under a single key (e.g. `'data'` → `{ "data": <body> }`)
53
+ * for all resources. Per-resource {@link ResourceDefinition.envelope} takes precedence.
54
+ * Error responses are never enveloped. Omit (or set `null`/`''`) for bare bodies — the
55
+ * default, and backward compatible.
56
+ */
57
+ envelope?: string | null;
51
58
  /**
52
59
  * API-wide read-through caching. Provide a `store` (defaults to an in-process
53
60
  * {@link InMemoryCacheStore}) and/or a default `ttlSeconds` applied to every resource that
@@ -169,6 +169,28 @@ async function sendError(error, res) {
169
169
  item['details'] = details;
170
170
  await res.status(status).json({ errors: [item] });
171
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.
177
+ */
178
+ function normalizeEnvelope(value) {
179
+ return typeof value === 'string' && value.length > 0 ? value : null;
180
+ }
181
+ /**
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
+ }
172
194
  /**
173
195
  * Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
174
196
  * @param req - The incoming HTTP request.
@@ -318,6 +340,9 @@ export function registerCrudApi(server, resources, options = {}) {
318
340
  // write-filtering, tenant auto-detection, cache namespace) works off a single source of
319
341
  // truth with all defaults already applied.
320
342
  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.
345
+ const envelope = normalizeEnvelope(resource.envelope !== undefined ? resource.envelope : options.envelope);
321
346
  // Resolve tenancy once at registration and fail closed on misconfiguration, so a
322
347
  // scoped resource can never be silently served unscoped at request time.
323
348
  const tenantField = effectiveTenantField(resource, options.tenant);
@@ -380,11 +405,11 @@ export function registerCrudApi(server, resources, options = {}) {
380
405
  const items = (Array.isArray(req.body) ? req.body : [req.body]).map((item) => filterWritableFields(resource, item));
381
406
  if (items.length === 1) {
382
407
  const result = await repo.createOne(items[0], createOptions);
383
- await res.status(201).json(result);
408
+ await writeSuccess(res, 201, result, envelope);
384
409
  return;
385
410
  }
386
411
  const results = await repo.createMany(items, createOptions);
387
- await res.status(201).json(results);
412
+ await writeSuccess(res, 201, results, envelope);
388
413
  }));
389
414
  }
390
415
  if (permissions.allowReadMany) {
@@ -393,7 +418,7 @@ export function registerCrudApi(server, resources, options = {}) {
393
418
  const repo = await resolveRepo(req, auth);
394
419
  const listOptions = parseListOptions(req.query, resource);
395
420
  const results = await repo.getMany(listOptions);
396
- await res.status(200).json(results);
421
+ await writeSuccess(res, 200, results, envelope);
397
422
  }));
398
423
  }
399
424
  if (permissions.allowReadManyWithQueryBuilder) {
@@ -406,7 +431,7 @@ export function registerCrudApi(server, resources, options = {}) {
406
431
  const query = { ...body };
407
432
  validateAdvancedQuery(resource, query);
408
433
  const results = await repo.executeQuery(query);
409
- await res.status(200).json(results);
434
+ await writeSuccess(res, 200, results, envelope);
410
435
  }));
411
436
  }
412
437
  if (permissions.allowReadOne) {
@@ -421,7 +446,7 @@ export function registerCrudApi(server, resources, options = {}) {
421
446
  });
422
447
  if (!result)
423
448
  throw new NotFoundError();
424
- await res.status(200).json(result);
449
+ await writeSuccess(res, 200, result, envelope);
425
450
  }));
426
451
  }
427
452
  if (permissions.allowUpdateOne) {
@@ -433,7 +458,7 @@ export function registerCrudApi(server, resources, options = {}) {
433
458
  const result = await repo.updateOne(id, body);
434
459
  if (!result)
435
460
  throw new NotFoundError();
436
- await res.status(200).json(result);
461
+ await writeSuccess(res, 200, result, envelope);
437
462
  }));
438
463
  }
439
464
  if (permissions.allowUpdateMany) {
@@ -451,7 +476,7 @@ export function registerCrudApi(server, resources, options = {}) {
451
476
  if (!query.where?.length)
452
477
  throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
453
478
  const result = await repo.updateMany(query, filteredUpdate);
454
- await res.status(200).json(result);
479
+ await writeSuccess(res, 200, result, envelope);
455
480
  }));
456
481
  }
457
482
  if (permissions.allowUpsertOne) {
@@ -463,7 +488,7 @@ export function registerCrudApi(server, resources, options = {}) {
463
488
  const id = parseId(req.params['id']);
464
489
  const body = filterWritableFields(resource, req.body);
465
490
  const result = await repo.upsertOne(id, body);
466
- await res.status(200).json(result);
491
+ await writeSuccess(res, 200, result, envelope);
467
492
  }));
468
493
  }
469
494
  if (permissions.allowDeleteOne) {
@@ -474,7 +499,7 @@ export function registerCrudApi(server, resources, options = {}) {
474
499
  const deleted = await repo.deleteOne(id);
475
500
  if (!deleted)
476
501
  throw new NotFoundError();
477
- await res.status(200).json({ deleted: true });
502
+ await writeSuccess(res, 200, { deleted: true }, envelope);
478
503
  }));
479
504
  }
480
505
  if (permissions.allowDeleteMany) {
@@ -489,7 +514,7 @@ export function registerCrudApi(server, resources, options = {}) {
489
514
  if (!query.where?.length)
490
515
  throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
491
516
  const result = await repo.deleteMany(query);
492
- await res.status(200).json(result);
517
+ await writeSuccess(res, 200, result, envelope);
493
518
  }));
494
519
  }
495
520
  // 405 fallbacks — only registered when at least one method exists for the path
@@ -380,6 +380,16 @@ export interface ResourceDefinition<TRecord = unknown, TCreate = Partial<TRecord
380
380
  * `false` to explicitly disable caching for this resource even when a default is configured.
381
381
  */
382
382
  cache?: ResourceCacheConfig | false;
383
+ /**
384
+ * Wrap every success response body for this resource under a single key
385
+ * (e.g. `'data'` →
386
+ * `{ "data": <body> }`). Applies uniformly to all success payloads — list
387
+ * (`{ data: { count, results } }`), single object, create/update/upsert, and the
388
+ * `{ deleted: true }` confirmation. Error responses are never enveloped.
389
+ * Overrides the API-wide {@link CrudApiOptions.envelope}. Omit (or set `null`/`''`) for
390
+ * a bare body — the default, and backward compatible.
391
+ */
392
+ envelope?: string | null;
383
393
  }
384
394
  /** Per-resource read-through cache configuration. */
385
395
  export interface ResourceCacheConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edium/halifax",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Auto-generate type-safe REST CRUD APIs from your data models. Adapter-driven: Express/Fastify/HyperExpress, Prisma (PostgreSQL, MySQL, MariaDB, SQL Server, CockroachDB, SQLite), JWT/API-key auth, multi-tenancy, a dynamic query builder, and pluggable Redis caching.",
5
5
  "author": "David LaTour <david@edium.com>",
6
6
  "homepage": "https://github.com/splayfee/halifax#readme",