@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 +14 -0
- package/README_AUTOCRUD.md +33 -0
- package/dist/core/crudRouter.d.ts +7 -0
- package/dist/core/crudRouter.js +35 -10
- package/dist/core/types.d.ts +10 -0
- package/package.json +1 -1
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**
|
package/README_AUTOCRUD.md
CHANGED
|
@@ -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
|
package/dist/core/crudRouter.js
CHANGED
|
@@ -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
|
|
408
|
+
await writeSuccess(res, 201, result, envelope);
|
|
384
409
|
return;
|
|
385
410
|
}
|
|
386
411
|
const results = await repo.createMany(items, createOptions);
|
|
387
|
-
await res
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|