@edium/halifax 1.0.0 → 2.0.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 +83 -0
- package/README.md +72 -50
- package/README_AUTOCRUD.md +61 -19
- package/README_QUERYBUILDER.md +1 -1
- package/README_REPO_ADAPTERS.md +80 -11
- package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
- package/dist/adapters/http/ExpressAdapter.js +20 -12
- package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
- package/dist/adapters/http/FastifyAdapter.js +125 -0
- package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
- package/dist/adapters/http/HyperExpressAdapter.js +128 -0
- package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
- package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
- package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
- package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
- package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
- package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
- package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
- package/dist/adapters/orm/prisma/helpers.js +0 -1
- package/dist/adapters/orm/prisma/index.d.ts +1 -2
- package/dist/adapters/orm/prisma/index.js +0 -1
- package/dist/adapters/orm/prisma/types.d.ts +14 -9
- package/dist/adapters/orm/prisma/types.js +0 -1
- package/dist/auth/AuthStrategy.d.ts +0 -9
- package/dist/auth/AuthStrategy.js +0 -7
- package/dist/core/cache/CacheStore.d.ts +25 -0
- package/dist/core/cache/CacheStore.js +1 -0
- package/dist/core/cache/createCachingRepository.d.ts +39 -0
- package/dist/core/cache/createCachingRepository.js +116 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
- package/dist/core/cache/index.d.ts +5 -0
- package/dist/core/cache/index.js +5 -0
- package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
- package/dist/core/cache/redis/RedisCacheStore.js +42 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
- package/dist/core/cache/redis/RedisLikeClient.js +1 -0
- package/dist/core/crudRouter.d.ts +65 -8
- package/dist/core/crudRouter.js +231 -95
- package/dist/core/queryString.d.ts +3 -3
- package/dist/core/queryString.js +16 -7
- package/dist/core/types.d.ts +141 -31
- package/dist/core/types.js +13 -1
- package/dist/core/validation.d.ts +12 -4
- package/dist/core/validation.js +33 -13
- package/dist/enums/SqlComparison.d.ts +13 -3
- package/dist/enums/SqlComparison.js +12 -2
- package/dist/enums/SqlOperator.d.ts +0 -1
- package/dist/enums/SqlOperator.js +0 -1
- package/dist/enums/SqlOrder.d.ts +0 -1
- package/dist/enums/SqlOrder.js +0 -1
- package/dist/errors/AuthenticationError.d.ts +0 -1
- package/dist/errors/AuthenticationError.js +0 -1
- package/dist/errors/AuthorizationError.d.ts +0 -1
- package/dist/errors/AuthorizationError.js +0 -1
- package/dist/errors/BadRequestError.d.ts +0 -1
- package/dist/errors/BadRequestError.js +0 -1
- package/dist/errors/HttpError.d.ts +0 -1
- package/dist/errors/HttpError.js +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts +0 -1
- package/dist/errors/MethodNotAllowedError.js +0 -1
- package/dist/errors/NotAcceptableError.d.ts +0 -1
- package/dist/errors/NotAcceptableError.js +0 -1
- package/dist/errors/NotFoundError.d.ts +0 -1
- package/dist/errors/NotFoundError.js +0 -1
- package/dist/errors/NotImplementedError.d.ts +0 -1
- package/dist/errors/NotImplementedError.js +0 -1
- package/dist/errors/ServerError.d.ts +0 -1
- package/dist/errors/ServerError.js +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts +0 -1
- package/dist/errors/UnprocessableEntityError.js +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js +0 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -3
- package/dist/interfaces/IQueryFilter.d.ts +1 -2
- package/dist/interfaces/IQueryFilter.js +0 -1
- package/dist/interfaces/IQueryOptions.d.ts +9 -9
- package/dist/interfaces/IQueryOptions.js +0 -1
- package/dist/interfaces/ISort.d.ts +0 -1
- package/dist/interfaces/ISort.js +0 -1
- package/package.json +10 -8
- package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
- package/dist/adapters/http/ExpressAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
- package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/helpers.js.map +0 -1
- package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/index.js.map +0 -1
- package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/types.js.map +0 -1
- package/dist/auth/AuthStrategy.d.ts.map +0 -1
- package/dist/auth/AuthStrategy.js.map +0 -1
- package/dist/classes/QueryBuilder.d.ts +0 -33
- package/dist/classes/QueryBuilder.d.ts.map +0 -1
- package/dist/classes/QueryBuilder.js +0 -262
- package/dist/classes/QueryBuilder.js.map +0 -1
- package/dist/core/crudRouter.d.ts.map +0 -1
- package/dist/core/crudRouter.js.map +0 -1
- package/dist/core/queryString.d.ts.map +0 -1
- package/dist/core/queryString.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js.map +0 -1
- package/dist/enums/SqlComparison.d.ts.map +0 -1
- package/dist/enums/SqlComparison.js.map +0 -1
- package/dist/enums/SqlOperator.d.ts.map +0 -1
- package/dist/enums/SqlOperator.js.map +0 -1
- package/dist/enums/SqlOrder.d.ts.map +0 -1
- package/dist/enums/SqlOrder.js.map +0 -1
- package/dist/errors/AuthenticationError.d.ts.map +0 -1
- package/dist/errors/AuthenticationError.js.map +0 -1
- package/dist/errors/AuthorizationError.d.ts.map +0 -1
- package/dist/errors/AuthorizationError.js.map +0 -1
- package/dist/errors/BadRequestError.d.ts.map +0 -1
- package/dist/errors/BadRequestError.js.map +0 -1
- package/dist/errors/HttpError.d.ts.map +0 -1
- package/dist/errors/HttpError.js.map +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
- package/dist/errors/MethodNotAllowedError.js.map +0 -1
- package/dist/errors/NotAcceptableError.d.ts.map +0 -1
- package/dist/errors/NotAcceptableError.js.map +0 -1
- package/dist/errors/NotFoundError.d.ts.map +0 -1
- package/dist/errors/NotFoundError.js.map +0 -1
- package/dist/errors/NotImplementedError.d.ts.map +0 -1
- package/dist/errors/NotImplementedError.js.map +0 -1
- package/dist/errors/ServerError.d.ts.map +0 -1
- package/dist/errors/ServerError.js.map +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
- package/dist/errors/UnprocessableEntityError.js.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/interfaces/IParamQuery.d.ts +0 -8
- package/dist/interfaces/IParamQuery.d.ts.map +0 -1
- package/dist/interfaces/IParamQuery.js +0 -2
- package/dist/interfaces/IParamQuery.js.map +0 -1
- package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
- package/dist/interfaces/IQueryFilter.js.map +0 -1
- package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
- package/dist/interfaces/IQueryOptions.js.map +0 -1
- package/dist/interfaces/ISort.d.ts.map +0 -1
- package/dist/interfaces/ISort.js.map +0 -1
package/dist/core/crudRouter.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { AllowAllAuthStrategy } from '../auth/AuthStrategy.js';
|
|
2
|
-
import {
|
|
3
|
-
import { BadRequestError } from '../errors/BadRequestError.js';
|
|
2
|
+
import { createCachingRepository, InMemoryCacheStore } from '../core/cache/index.js';
|
|
4
3
|
import { HttpError } from '../errors/HttpError.js';
|
|
5
4
|
import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
|
|
6
5
|
import { NotAcceptableError } from '../errors/NotAcceptableError.js';
|
|
@@ -10,41 +9,122 @@ import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js'
|
|
|
10
9
|
import { UnsupportedMediaTypeError } from '../errors/UnsupportedMediaTypeError.js';
|
|
11
10
|
import { defaultCrudPermissions } from '../core/types.js';
|
|
12
11
|
import { parseListOptions } from '../core/queryString.js';
|
|
13
|
-
import { validateAdvancedQuery, validateId, isValidUuid } from '../core/validation.js';
|
|
12
|
+
import { validateAdvancedQuery, validateId, isValidUuid, isValidObjectId } from '../core/validation.js';
|
|
14
13
|
import { ServerError } from '../errors/ServerError.js';
|
|
15
14
|
import { AuthorizationError } from '../errors/AuthorizationError.js';
|
|
16
15
|
/**
|
|
17
16
|
* Parses and validates a raw `:id` route parameter.
|
|
18
17
|
* @param raw - The raw string value from `req.params.id`.
|
|
19
|
-
* @returns A parsed integer for numeric IDs, or the original string for
|
|
20
|
-
* @throws {@link BadRequestError} when the value is not a valid integer or
|
|
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.
|
|
21
20
|
*/
|
|
22
21
|
function parseId(raw) {
|
|
23
22
|
validateId(raw);
|
|
24
|
-
|
|
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
25
|
return raw;
|
|
26
26
|
return typeof raw === 'string' ? parseInt(raw, 10) : raw;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Strips non-writable fields from a request body and rejects unknown fields with a 422.
|
|
30
|
-
*
|
|
31
|
-
* `writable
|
|
32
|
-
*
|
|
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.
|
|
33
34
|
* @param data - The raw request body key-value map.
|
|
34
|
-
* @returns A new object containing only
|
|
35
|
+
* @returns A new object containing only writable fields.
|
|
35
36
|
* @throws {@link UnprocessableEntityError} when the body contains keys not defined on the resource.
|
|
36
37
|
*/
|
|
37
38
|
function filterWritableFields(resource, data) {
|
|
38
|
-
const
|
|
39
|
+
const fields = resource.fields ?? [];
|
|
40
|
+
const knownFields = new Set(fields.map((f) => f.name));
|
|
39
41
|
const unknownFields = Object.keys(data).filter((key) => !knownFields.has(key));
|
|
40
42
|
if (unknownFields.length) {
|
|
41
43
|
throw new UnprocessableEntityError(`Unknown field(s): ${unknownFields.join(', ')}.`);
|
|
42
44
|
}
|
|
43
45
|
return Object.fromEntries(Object.entries(data).filter(([key]) => {
|
|
44
|
-
const field =
|
|
45
|
-
return field?.writable
|
|
46
|
+
const field = fields.find((f) => f.name === key);
|
|
47
|
+
return field?.writable !== false;
|
|
46
48
|
}));
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Derives a human-readable resource name from a route prefix when none is given:
|
|
52
|
+
* 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
|
+
*/
|
|
56
|
+
function deriveResourceName(routePrefix) {
|
|
57
|
+
return (routePrefix
|
|
58
|
+
.split(/[-_/\s]+/)
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
61
|
+
.join(' ') || routePrefix);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
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.
|
|
76
|
+
* @throws {@link ServerError} when neither the repository nor the resource provides any fields.
|
|
77
|
+
*/
|
|
78
|
+
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()];
|
|
85
|
+
if (merged.length === 0) {
|
|
86
|
+
throw new ServerError(`Resource '${resource.name ?? resource.routePrefix}' has no fields. Provide 'fields', ` +
|
|
87
|
+
`or construct its repository with a model so the schema can be derived.`);
|
|
88
|
+
}
|
|
89
|
+
return merged.map((field) => ({
|
|
90
|
+
name: field.name,
|
|
91
|
+
filterable: field.filterable !== false,
|
|
92
|
+
sortable: field.sortable !== false,
|
|
93
|
+
selectable: field.selectable !== false,
|
|
94
|
+
// 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
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 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
|
+
*/
|
|
103
|
+
function resolveRelations(resource) {
|
|
104
|
+
const byName = new Map();
|
|
105
|
+
for (const relation of resource.repository?.relations ?? [])
|
|
106
|
+
byName.set(relation.name, relation);
|
|
107
|
+
for (const relation of resource.relations ?? [])
|
|
108
|
+
byName.set(relation.name, relation);
|
|
109
|
+
return [...byName.values()];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
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`.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeResource(resource) {
|
|
120
|
+
const idField = resource.repository?.idField ?? 'id';
|
|
121
|
+
return {
|
|
122
|
+
...resource,
|
|
123
|
+
name: resource.name ?? deriveResourceName(resource.routePrefix),
|
|
124
|
+
fields: resolveFields(resource, idField),
|
|
125
|
+
relations: resolveRelations(resource)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
48
128
|
/** Maps HTTP status codes to machine-readable error code strings. */
|
|
49
129
|
const statusCodeMap = {
|
|
50
130
|
400: 'BAD_REQUEST',
|
|
@@ -77,35 +157,6 @@ export function normalizeError(error) {
|
|
|
77
157
|
}
|
|
78
158
|
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
|
|
79
159
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Validates that all identifier-shaped values in a preview query are safe SQL identifiers.
|
|
82
|
-
* Field names and the table name are interpolated directly into SQL strings (not parameterized),
|
|
83
|
-
* so they must match `[a-zA-Z_][a-zA-Z0-9_.]*` to prevent unexpected SQL fragments.
|
|
84
|
-
* @param query - The query AST to inspect.
|
|
85
|
-
* @throws {@link BadRequestError} when any identifier contains disallowed characters.
|
|
86
|
-
*/
|
|
87
|
-
function assertSafePreviewIdentifiers(query) {
|
|
88
|
-
const safe = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
89
|
-
const check = (value, label) => {
|
|
90
|
-
if (!safe.test(value))
|
|
91
|
-
throw new BadRequestError(`Invalid ${label}: '${value}'.`);
|
|
92
|
-
};
|
|
93
|
-
if (query.tableName)
|
|
94
|
-
check(query.tableName, 'table name');
|
|
95
|
-
for (const f of query.fields ?? [])
|
|
96
|
-
check(f, 'field name');
|
|
97
|
-
for (const s of query.orderBy ?? [])
|
|
98
|
-
check(s.field, 'sort field');
|
|
99
|
-
const checkFilters = (filters) => {
|
|
100
|
-
for (const f of filters) {
|
|
101
|
-
if (f.field)
|
|
102
|
-
check(f.field, 'filter field');
|
|
103
|
-
if (f.children?.length)
|
|
104
|
-
checkFilters(f.children);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
checkFilters(query.where ?? []);
|
|
108
|
-
}
|
|
109
160
|
/**
|
|
110
161
|
* Serialises a caught error and writes it as a JSON `{ errors: [...] }` response.
|
|
111
162
|
* @param error - The caught value to serialise.
|
|
@@ -124,6 +175,7 @@ async function sendError(error, res) {
|
|
|
124
175
|
* @param resource - The resource being accessed (used to look up required permissions).
|
|
125
176
|
* @param action - The CRUD action being performed.
|
|
126
177
|
* @param authStrategy - The active auth strategy.
|
|
178
|
+
* @returns The resolved {@link AuthContext} (so callers can derive the tenant scope from it).
|
|
127
179
|
*/
|
|
128
180
|
async function authorizeRequest(req, resource, action, authStrategy) {
|
|
129
181
|
const auth = await authStrategy.authenticate(req);
|
|
@@ -138,7 +190,7 @@ async function authorizeRequest(req, resource, action, authStrategy) {
|
|
|
138
190
|
});
|
|
139
191
|
if (!allowed)
|
|
140
192
|
throw new AuthorizationError();
|
|
141
|
-
return;
|
|
193
|
+
return auth;
|
|
142
194
|
}
|
|
143
195
|
if (requiredPermissions.length) {
|
|
144
196
|
const permissions = new Set(auth.permissions ?? []);
|
|
@@ -147,7 +199,28 @@ async function authorizeRequest(req, resource, action, authStrategy) {
|
|
|
147
199
|
if (!allowed)
|
|
148
200
|
throw new AuthorizationError();
|
|
149
201
|
}
|
|
202
|
+
return auth;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Determines the column a resource is tenant-scoped on, applying this precedence:
|
|
206
|
+
* explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
|
|
207
|
+
* tenant field when the resource actually has it → otherwise unscoped (global).
|
|
208
|
+
* @param resource - The resource being inspected.
|
|
209
|
+
* @param tenant - The API-wide tenant options, or `undefined` when tenancy is off.
|
|
210
|
+
* @returns The tenant field name, or `null` when the resource is not scoped.
|
|
211
|
+
*/
|
|
212
|
+
function effectiveTenantField(resource, tenant) {
|
|
213
|
+
if (!tenant)
|
|
214
|
+
return null;
|
|
215
|
+
if (resource.tenant === false)
|
|
216
|
+
return null;
|
|
217
|
+
if (resource.tenant && resource.tenant.field)
|
|
218
|
+
return resource.tenant.field;
|
|
219
|
+
const fallback = tenant.field ?? 'tenantId';
|
|
220
|
+
return (resource.fields ?? []).some((f) => f.name === fallback) ? fallback : null;
|
|
150
221
|
}
|
|
222
|
+
/** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
|
|
223
|
+
const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
151
224
|
/**
|
|
152
225
|
* Reads a single header value by name (case-insensitive).
|
|
153
226
|
* @param req - The incoming HTTP request.
|
|
@@ -159,6 +232,22 @@ function getHeaderValue(req, name) {
|
|
|
159
232
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
160
233
|
return typeof value === 'string' ? value : undefined;
|
|
161
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Decides whether the request asks to bypass (bust) the cache. For the standard
|
|
237
|
+
* `Cache-Control` header this means a `no-cache`/`no-store` directive; for any custom
|
|
238
|
+
* bust header, mere presence (with a non-empty value) triggers a refresh.
|
|
239
|
+
* @param req - The incoming HTTP request.
|
|
240
|
+
* @param header - The configured bust header name.
|
|
241
|
+
* @returns `true` when the cache should be force-refreshed for this request.
|
|
242
|
+
*/
|
|
243
|
+
function wantsCacheBust(req, header) {
|
|
244
|
+
const value = getHeaderValue(req, header);
|
|
245
|
+
if (!value)
|
|
246
|
+
return false;
|
|
247
|
+
if (header.toLowerCase() === 'cache-control')
|
|
248
|
+
return /no-cache|no-store/i.test(value);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
162
251
|
/**
|
|
163
252
|
* Throws {@link UnsupportedMediaTypeError} when a body-carrying request uses a non-JSON Content-Type.
|
|
164
253
|
* @param req - The incoming HTTP request to check.
|
|
@@ -209,66 +298,124 @@ function wrap(handler) {
|
|
|
209
298
|
* Registers all CRUD routes for every resource on the given HTTP server.
|
|
210
299
|
*
|
|
211
300
|
* Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
|
|
212
|
-
* A global query-builder preview endpoint is also registered at `previewQueryBuilderPath`.
|
|
213
301
|
*
|
|
214
302
|
* @param server - The HTTP server adapter to register routes on (e.g. {@link ExpressHttpServer}).
|
|
215
303
|
* @param resources - Resource definitions to wire up as CRUD endpoints.
|
|
216
|
-
* @param options - Auth strategy
|
|
304
|
+
* @param options - Auth strategy and query-builder path overrides.
|
|
217
305
|
*/
|
|
218
306
|
export function registerCrudApi(server, resources, options = {}) {
|
|
219
307
|
const authStrategy = options.authStrategy ?? new AllowAllAuthStrategy();
|
|
220
|
-
const queryBuilderPath = options.queryBuilderPath ?? 'query
|
|
221
|
-
|
|
222
|
-
resources.
|
|
223
|
-
|
|
308
|
+
const queryBuilderPath = options.queryBuilderPath ?? 'query';
|
|
309
|
+
// One shared cache store for the whole API (created lazily only if any resource caches),
|
|
310
|
+
// so version-based invalidation is consistent across requests and resources.
|
|
311
|
+
const cacheStore = options.cache?.store ?? new InMemoryCacheStore();
|
|
312
|
+
const bustHeader = options.cache?.bustHeader ?? 'cache-control';
|
|
313
|
+
resources.forEach((rawResource) => {
|
|
314
|
+
const repository = rawResource.repository;
|
|
224
315
|
if (!repository)
|
|
225
|
-
throw new ServerError(`Resource '${
|
|
316
|
+
throw new ServerError(`Resource '${rawResource.name ?? rawResource.routePrefix}' does not define a repository.`);
|
|
317
|
+
// Resolve name + field/relation schema once, here, so every downstream stage (validation,
|
|
318
|
+
// write-filtering, tenant auto-detection, cache namespace) works off a single source of
|
|
319
|
+
// truth with all defaults already applied.
|
|
320
|
+
const resource = normalizeResource(rawResource);
|
|
321
|
+
// Resolve tenancy once at registration and fail closed on misconfiguration, so a
|
|
322
|
+
// scoped resource can never be silently served unscoped at request time.
|
|
323
|
+
const tenantField = effectiveTenantField(resource, options.tenant);
|
|
324
|
+
if (tenantField) {
|
|
325
|
+
if (!safeIdentifier.test(tenantField))
|
|
326
|
+
throw new ServerError(`Resource '${resource.name}' has an unsafe tenant field name '${tenantField}'.`);
|
|
327
|
+
if (!repository.withScope)
|
|
328
|
+
throw new ServerError(`Resource '${resource.name}' is tenant-scoped on '${tenantField}' but its repository ` +
|
|
329
|
+
`does not implement withScope(). Refusing to serve it unscoped.`);
|
|
330
|
+
}
|
|
331
|
+
// Effective cache TTL: an explicit `cache: false` disables; otherwise per-resource config
|
|
332
|
+
// wins over the API-wide default. `0` means "never expire" (so `undefined` = no caching).
|
|
333
|
+
const cacheTtl = resource.cache === false
|
|
334
|
+
? undefined
|
|
335
|
+
: (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
|
|
336
|
+
const cachingEnabled = cacheTtl !== undefined;
|
|
337
|
+
/**
|
|
338
|
+
* Wraps a repository in the read-through cache when caching is enabled for this resource.
|
|
339
|
+
* The namespace embeds the resource name and tenant key, so one tenant can never read
|
|
340
|
+
* another's cached rows. `bust` (from the cache-bust header) force-refreshes this request.
|
|
341
|
+
* @param repo - The (possibly tenant-scoped) repository for this request.
|
|
342
|
+
* @param scopeKey - Stable key for the current tenant scope (or `'global'`).
|
|
343
|
+
* @param bust - Whether the caller requested a cache-busting refresh.
|
|
344
|
+
* @returns The repository, cache-wrapped when caching is enabled.
|
|
345
|
+
*/
|
|
346
|
+
const withCache = (repo, scopeKey, bust) => cachingEnabled
|
|
347
|
+
? createCachingRepository(repo, {
|
|
348
|
+
store: cacheStore,
|
|
349
|
+
ttlSeconds: cacheTtl,
|
|
350
|
+
namespace: `${resource.name}:${scopeKey}`,
|
|
351
|
+
bust
|
|
352
|
+
})
|
|
353
|
+
: repo;
|
|
354
|
+
/**
|
|
355
|
+
* Returns the repository to use for this request: the tenant-scoped clone when the
|
|
356
|
+
* resource is scoped, or the bare repository otherwise — wrapped in the read-through
|
|
357
|
+
* cache when enabled. Throws 403 in strict mode (the default) when a scoped resource
|
|
358
|
+
* cannot resolve a tenant for the caller.
|
|
359
|
+
*/
|
|
360
|
+
const resolveRepo = async (req, auth) => {
|
|
361
|
+
const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
|
|
362
|
+
if (!tenantField || !options.tenant)
|
|
363
|
+
return withCache(repository, 'global', bust);
|
|
364
|
+
const value = await options.tenant.resolveId({ auth, req, resource });
|
|
365
|
+
if (value === undefined || value === null || value === '') {
|
|
366
|
+
if (options.tenant.strict !== false)
|
|
367
|
+
throw new AuthorizationError('No tenant is associated with this request.');
|
|
368
|
+
return withCache(repository, 'global', bust);
|
|
369
|
+
}
|
|
370
|
+
return withCache(repository.withScope({ field: tenantField, value }), String(value), bust);
|
|
371
|
+
};
|
|
226
372
|
const permissions = { ...defaultCrudPermissions, ...resource.permissions };
|
|
227
373
|
const basePath = `/${resource.routePrefix}`;
|
|
228
374
|
if (permissions.allowCreate) {
|
|
229
375
|
server.registerRoute('POST', basePath, wrap(async (req, res) => {
|
|
230
|
-
await authorizeRequest(req, resource, 'create', authStrategy);
|
|
376
|
+
const auth = await authorizeRequest(req, resource, 'create', authStrategy);
|
|
377
|
+
const repo = await resolveRepo(req, auth);
|
|
231
378
|
const idempotencyKey = getHeaderValue(req, 'idempotency-key');
|
|
232
379
|
const createOptions = idempotencyKey ? { idempotencyKey } : undefined;
|
|
233
380
|
const items = (Array.isArray(req.body) ? req.body : [req.body]).map((item) => filterWritableFields(resource, item));
|
|
234
381
|
if (items.length === 1) {
|
|
235
|
-
const result = await
|
|
382
|
+
const result = await repo.createOne(items[0], createOptions);
|
|
236
383
|
await res.status(201).json(result);
|
|
237
384
|
return;
|
|
238
385
|
}
|
|
239
|
-
const results = await
|
|
386
|
+
const results = await repo.createMany(items, createOptions);
|
|
240
387
|
await res.status(201).json(results);
|
|
241
388
|
}));
|
|
242
389
|
}
|
|
243
390
|
if (permissions.allowReadMany) {
|
|
244
391
|
server.registerRoute('GET', basePath, wrap(async (req, res) => {
|
|
245
|
-
await authorizeRequest(req, resource, 'readMany', authStrategy);
|
|
392
|
+
const auth = await authorizeRequest(req, resource, 'readMany', authStrategy);
|
|
393
|
+
const repo = await resolveRepo(req, auth);
|
|
246
394
|
const listOptions = parseListOptions(req.query, resource);
|
|
247
|
-
const results = await
|
|
395
|
+
const results = await repo.getMany(listOptions);
|
|
248
396
|
await res.status(200).json(results);
|
|
249
397
|
}));
|
|
250
398
|
}
|
|
251
399
|
if (permissions.allowReadManyWithQueryBuilder) {
|
|
252
400
|
server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
|
|
253
|
-
await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
|
|
254
|
-
|
|
401
|
+
const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
|
|
402
|
+
const repo = await resolveRepo(req, auth);
|
|
403
|
+
if (!repo.executeQuery)
|
|
255
404
|
throw new NotImplementedError('This resource does not support the query builder.');
|
|
256
405
|
const body = (req.body ?? {});
|
|
257
|
-
const query = {
|
|
258
|
-
...body,
|
|
259
|
-
tableName: resource.tableName
|
|
260
|
-
};
|
|
406
|
+
const query = { ...body };
|
|
261
407
|
validateAdvancedQuery(resource, query);
|
|
262
|
-
const results = await
|
|
408
|
+
const results = await repo.executeQuery(query);
|
|
263
409
|
await res.status(200).json(results);
|
|
264
410
|
}));
|
|
265
411
|
}
|
|
266
412
|
if (permissions.allowReadOne) {
|
|
267
413
|
server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
|
|
268
|
-
await authorizeRequest(req, resource, 'readOne', authStrategy);
|
|
414
|
+
const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
|
|
415
|
+
const repo = await resolveRepo(req, auth);
|
|
269
416
|
const id = parseId(req.params['id']);
|
|
270
417
|
const listOptions = parseListOptions(req.query, resource);
|
|
271
|
-
const result = await
|
|
418
|
+
const result = await repo.getOne(id, {
|
|
272
419
|
fields: listOptions.fields,
|
|
273
420
|
include: listOptions.include
|
|
274
421
|
});
|
|
@@ -279,10 +426,11 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
279
426
|
}
|
|
280
427
|
if (permissions.allowUpdateOne) {
|
|
281
428
|
server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
|
|
282
|
-
await authorizeRequest(req, resource, 'updateOne', authStrategy);
|
|
429
|
+
const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
|
|
430
|
+
const repo = await resolveRepo(req, auth);
|
|
283
431
|
const id = parseId(req.params['id']);
|
|
284
432
|
const body = filterWritableFields(resource, req.body);
|
|
285
|
-
const result = await
|
|
433
|
+
const result = await repo.updateOne(id, body);
|
|
286
434
|
if (!result)
|
|
287
435
|
throw new NotFoundError();
|
|
288
436
|
await res.status(200).json(result);
|
|
@@ -290,40 +438,40 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
290
438
|
}
|
|
291
439
|
if (permissions.allowUpdateMany) {
|
|
292
440
|
server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
|
|
293
|
-
await authorizeRequest(req, resource, 'updateMany', authStrategy);
|
|
294
|
-
|
|
441
|
+
const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
|
|
442
|
+
const repo = await resolveRepo(req, auth);
|
|
443
|
+
if (!repo.updateMany)
|
|
295
444
|
throw new NotImplementedError('This resource does not support updateMany.');
|
|
296
445
|
const { update, ...queryBody } = (req.body ?? {});
|
|
297
446
|
const filteredUpdate = filterWritableFields(resource, (update ?? {}));
|
|
298
447
|
if (!Object.keys(filteredUpdate).length)
|
|
299
448
|
throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
|
|
300
|
-
const query = {
|
|
301
|
-
...queryBody,
|
|
302
|
-
tableName: resource.tableName
|
|
303
|
-
};
|
|
449
|
+
const query = { ...queryBody };
|
|
304
450
|
validateAdvancedQuery(resource, query);
|
|
305
451
|
if (!query.where?.length)
|
|
306
452
|
throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
|
|
307
|
-
const result = await
|
|
453
|
+
const result = await repo.updateMany(query, filteredUpdate);
|
|
308
454
|
await res.status(200).json(result);
|
|
309
455
|
}));
|
|
310
456
|
}
|
|
311
457
|
if (permissions.allowUpsertOne) {
|
|
312
458
|
server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
|
|
313
|
-
await authorizeRequest(req, resource, 'upsertOne', authStrategy);
|
|
314
|
-
|
|
459
|
+
const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
|
|
460
|
+
const repo = await resolveRepo(req, auth);
|
|
461
|
+
if (!repo.upsertOne)
|
|
315
462
|
throw new NotImplementedError('This resource does not support upsert.');
|
|
316
463
|
const id = parseId(req.params['id']);
|
|
317
464
|
const body = filterWritableFields(resource, req.body);
|
|
318
|
-
const result = await
|
|
465
|
+
const result = await repo.upsertOne(id, body);
|
|
319
466
|
await res.status(200).json(result);
|
|
320
467
|
}));
|
|
321
468
|
}
|
|
322
469
|
if (permissions.allowDeleteOne) {
|
|
323
470
|
server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
|
|
324
|
-
await authorizeRequest(req, resource, 'deleteOne', authStrategy);
|
|
471
|
+
const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
|
|
472
|
+
const repo = await resolveRepo(req, auth);
|
|
325
473
|
const id = parseId(req.params['id']);
|
|
326
|
-
const deleted = await
|
|
474
|
+
const deleted = await repo.deleteOne(id);
|
|
327
475
|
if (!deleted)
|
|
328
476
|
throw new NotFoundError();
|
|
329
477
|
await res.status(200).json({ deleted: true });
|
|
@@ -331,18 +479,16 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
331
479
|
}
|
|
332
480
|
if (permissions.allowDeleteMany) {
|
|
333
481
|
server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
|
|
334
|
-
await authorizeRequest(req, resource, 'deleteMany', authStrategy);
|
|
335
|
-
|
|
482
|
+
const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
|
|
483
|
+
const repo = await resolveRepo(req, auth);
|
|
484
|
+
if (!repo.deleteMany)
|
|
336
485
|
throw new NotImplementedError('This resource does not support deleteMany.');
|
|
337
486
|
const body = (req.body ?? {});
|
|
338
|
-
const query = {
|
|
339
|
-
...body,
|
|
340
|
-
tableName: resource.tableName
|
|
341
|
-
};
|
|
487
|
+
const query = { ...body };
|
|
342
488
|
validateAdvancedQuery(resource, query);
|
|
343
489
|
if (!query.where?.length)
|
|
344
490
|
throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
|
|
345
|
-
const result = await
|
|
491
|
+
const result = await repo.deleteMany(query);
|
|
346
492
|
await res.status(200).json(result);
|
|
347
493
|
}));
|
|
348
494
|
}
|
|
@@ -378,14 +524,4 @@ export function registerCrudApi(server, resources, options = {}) {
|
|
|
378
524
|
});
|
|
379
525
|
}
|
|
380
526
|
});
|
|
381
|
-
server.registerRoute('POST', previewPath, wrap(async (req, res) => {
|
|
382
|
-
await authStrategy.authenticate(req);
|
|
383
|
-
const query = req.body;
|
|
384
|
-
assertSafePreviewIdentifiers(query);
|
|
385
|
-
await res.status(200).json({
|
|
386
|
-
count: QueryBuilder.buildCountQuery(query),
|
|
387
|
-
select: QueryBuilder.buildSelectQuery(query)
|
|
388
|
-
});
|
|
389
|
-
}));
|
|
390
527
|
}
|
|
391
|
-
//# sourceMappingURL=crudRouter.js.map
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ListOptions, type ResourceDefinition } from '../core/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
4
4
|
*
|
|
5
5
|
* Validates field names, sort fields, includes, and filter keys against the resource schema.
|
|
6
|
-
* Applies
|
|
6
|
+
* Applies the resource's `defaultLimit`/`maxLimit`, falling back to the framework page
|
|
7
|
+
* defaults so the result set is always bounded.
|
|
7
8
|
*
|
|
8
9
|
* @param query - The raw query-string object from the HTTP request.
|
|
9
10
|
* @param resource - The resource definition used for field and relation validation.
|
|
10
11
|
* @returns Typed list options ready to pass to `repository.getMany()`.
|
|
11
12
|
*/
|
|
12
13
|
export declare function parseListOptions(query: Record<string, unknown>, resource: ResourceDefinition): ListOptions;
|
|
13
|
-
//# sourceMappingURL=queryString.d.ts.map
|
package/dist/core/queryString.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SqlOrder } from '../enums/SqlOrder.js';
|
|
2
2
|
import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
|
|
3
|
+
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../core/types.js';
|
|
3
4
|
import { isValidInt32, validateFields, validateIncludes, validateQueryString, validateSelectableFields, validateSortableFields } from '../core/validation.js';
|
|
4
5
|
/**
|
|
5
6
|
* Parses and validates a single integer query-string value.
|
|
@@ -34,7 +35,8 @@ function parseCsv(value) {
|
|
|
34
35
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
35
36
|
*
|
|
36
37
|
* Validates field names, sort fields, includes, and filter keys against the resource schema.
|
|
37
|
-
* Applies
|
|
38
|
+
* Applies the resource's `defaultLimit`/`maxLimit`, falling back to the framework page
|
|
39
|
+
* defaults so the result set is always bounded.
|
|
38
40
|
*
|
|
39
41
|
* @param query - The raw query-string object from the HTTP request.
|
|
40
42
|
* @param resource - The resource definition used for field and relation validation.
|
|
@@ -47,10 +49,18 @@ export function parseListOptions(query, resource) {
|
|
|
47
49
|
let limit = parseInteger(query.limit, 'limit');
|
|
48
50
|
const offset = parseInteger(query.offset, 'offset', 0);
|
|
49
51
|
const order = parseCsv(query.order);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Page size is bounded by sensible defaults, but a resource can opt out with `0` (= no
|
|
53
|
+
// bound). `count` in the response always reflects the true total, so a capped page is never
|
|
54
|
+
// a silent drop. Precedence: an explicit `?limit=` wins; otherwise the resource/default
|
|
55
|
+
// `defaultLimit` applies; finally a non-zero `maxLimit` caps the result either way.
|
|
56
|
+
const cap = resource.maxLimit ?? MAX_PAGE_LIMIT;
|
|
57
|
+
if (limit === undefined) {
|
|
58
|
+
const fallback = resource.defaultLimit ?? DEFAULT_PAGE_LIMIT;
|
|
59
|
+
if (fallback !== 0)
|
|
60
|
+
limit = fallback;
|
|
61
|
+
}
|
|
62
|
+
if (cap !== 0 && (limit === undefined || limit > cap))
|
|
63
|
+
limit = cap;
|
|
54
64
|
if (fields) {
|
|
55
65
|
validateFields(resource, fields);
|
|
56
66
|
validateSelectableFields(resource, fields);
|
|
@@ -66,7 +76,7 @@ export function parseListOptions(query, resource) {
|
|
|
66
76
|
validateSortableFields(resource, orderFields);
|
|
67
77
|
}
|
|
68
78
|
const where = {};
|
|
69
|
-
const fieldNames = new Set(resource.fields.map((field) => field.name));
|
|
79
|
+
const fieldNames = new Set((resource.fields ?? []).map((field) => field.name));
|
|
70
80
|
Object.entries(query).forEach(([key, value]) => {
|
|
71
81
|
if (!fieldNames.has(key))
|
|
72
82
|
return;
|
|
@@ -86,4 +96,3 @@ export function parseListOptions(query, resource) {
|
|
|
86
96
|
})
|
|
87
97
|
};
|
|
88
98
|
}
|
|
89
|
-
//# sourceMappingURL=queryString.js.map
|