@edium/halifax 2.2.2 → 2.3.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 +161 -1
- package/README.md +15 -15
- package/README_AUTH.md +2 -2
- package/README_AUTOCRUD.md +5 -4
- package/README_CACHE.md +6 -0
- package/README_CLASSES.md +13 -6
- package/README_INTERFACES.md +13 -11
- package/README_OPENAPI.md +1 -1
- package/README_REPO_ADAPTERS.md +10 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +57 -14
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
- package/dist/adapters/orm/prisma/PrismaAdapter.js +149 -39
- package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
- package/dist/auth/strategies/PassportStrategies.js +3 -9
- package/dist/auth/strategies/types.d.ts +7 -0
- package/dist/auth/strategies/types.js +13 -1
- package/dist/core/cache/CacheStore.d.ts +12 -0
- package/dist/core/cache/createCachingRepository.js +10 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
- package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
- package/dist/core/cache/redis/RedisCacheStore.js +14 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
- package/dist/core/crudRouter.js +2 -20
- package/dist/core/fields.d.ts +11 -1
- package/dist/core/fields.js +19 -0
- package/dist/core/handlerUtils.d.ts +6 -0
- package/dist/core/handlerUtils.js +16 -11
- package/dist/core/handlers/create.js +3 -2
- package/dist/core/handlers/query.js +3 -5
- package/dist/core/handlers/readMany.js +3 -5
- package/dist/core/handlers/readOne.js +3 -6
- package/dist/core/handlers/updateMany.js +3 -4
- package/dist/core/queryString.d.ts +10 -0
- package/dist/core/queryString.js +23 -0
- package/dist/core/validation.js +5 -11
- package/dist/errors/ConflictError.d.ts +5 -0
- package/dist/errors/ConflictError.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/openapi/specGenerator.js +24 -19
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkRequiredPermissions } from '../auth/strategies/types.js';
|
|
1
2
|
import { HttpError } from '../errors/HttpError.js';
|
|
2
3
|
import { NotAcceptableError } from '../errors/NotAcceptableError.js';
|
|
3
4
|
import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
|
|
@@ -13,6 +14,7 @@ const statusCodeMap = {
|
|
|
13
14
|
404: 'NOT_FOUND',
|
|
14
15
|
405: 'METHOD_NOT_ALLOWED',
|
|
15
16
|
406: 'NOT_ACCEPTABLE',
|
|
17
|
+
409: 'CONFLICT',
|
|
16
18
|
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
17
19
|
422: 'UNPROCESSABLE_ENTITY',
|
|
18
20
|
501: 'NOT_IMPLEMENTED'
|
|
@@ -62,9 +64,9 @@ export function writeSuccess(res, status, body, envelope) {
|
|
|
62
64
|
*/
|
|
63
65
|
export function parseId(raw) {
|
|
64
66
|
validateId(raw);
|
|
65
|
-
if (
|
|
67
|
+
if (isValidUuid(raw) || isValidObjectId(raw))
|
|
66
68
|
return raw;
|
|
67
|
-
return
|
|
69
|
+
return parseInt(raw, 10);
|
|
68
70
|
}
|
|
69
71
|
/**
|
|
70
72
|
* Strips non-writable fields from a request body and rejects unknown fields with a 422.
|
|
@@ -95,12 +97,20 @@ export function filterWritableFields(resource, data, auth) {
|
|
|
95
97
|
* Fast-path returns the record unchanged when no fields carry read restrictions.
|
|
96
98
|
*/
|
|
97
99
|
export function filterReadableFields(resource, record, auth) {
|
|
100
|
+
return makeReadableFieldFilter(resource, auth)(record);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Returns a reusable filter function that strips read-restricted fields.
|
|
104
|
+
* Build this once per request (outside a `.map()` loop) so the fieldMap and
|
|
105
|
+
* userRoles Set are not reconstructed for every record in a bulk response.
|
|
106
|
+
*/
|
|
107
|
+
export function makeReadableFieldFilter(resource, auth) {
|
|
98
108
|
const fields = resource.fields ?? [];
|
|
99
109
|
if (!fields.some((f) => (f.readRoles?.length ?? 0) > 0))
|
|
100
|
-
return
|
|
110
|
+
return (r) => r;
|
|
101
111
|
const fieldMap = new Map(fields.map((f) => [f.name, f]));
|
|
102
112
|
const userRoles = new Set([...(auth?.roles ?? []), ...(auth?.permissions ?? [])]);
|
|
103
|
-
return Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
113
|
+
return (record) => Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
104
114
|
const field = fieldMap.get(key);
|
|
105
115
|
if (!field?.readRoles?.length)
|
|
106
116
|
return true;
|
|
@@ -125,13 +135,8 @@ export async function authorizeRequest(req, resource, action, authStrategy) {
|
|
|
125
135
|
throw new AuthorizationError();
|
|
126
136
|
return auth;
|
|
127
137
|
}
|
|
128
|
-
if (requiredPermissions
|
|
129
|
-
|
|
130
|
-
const roles = new Set(auth.roles ?? []);
|
|
131
|
-
const allowed = requiredPermissions.some((permission) => permissions.has(permission) || roles.has(permission));
|
|
132
|
-
if (!allowed)
|
|
133
|
-
throw new AuthorizationError();
|
|
134
|
-
}
|
|
138
|
+
if (!checkRequiredPermissions(auth, requiredPermissions))
|
|
139
|
+
throw new AuthorizationError();
|
|
135
140
|
return auth;
|
|
136
141
|
}
|
|
137
142
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
1
|
+
import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
2
2
|
export function registerCreate(server, basePath, ctx) {
|
|
3
3
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
4
4
|
server.registerRoute('POST', basePath, wrap(async (req, res) => {
|
|
@@ -21,6 +21,7 @@ export function registerCreate(server, basePath, ctx) {
|
|
|
21
21
|
const results = hooks?.afterCreate
|
|
22
22
|
? await Promise.all(rawResults.map((r) => applyHook(hooks.afterCreate, r, hookCtx)))
|
|
23
23
|
: rawResults;
|
|
24
|
-
|
|
24
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
25
|
+
await writeSuccess(res, 201, results.map(filterRecord), envelope);
|
|
25
26
|
}));
|
|
26
27
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateAdvancedQuery } from '../../core/validation.js';
|
|
2
2
|
import { NotImplementedError } from '../../errors/NotImplementedError.js';
|
|
3
|
-
import { applyHook, authorizeRequest,
|
|
3
|
+
import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
4
4
|
export function registerQuery(server, basePath, queryBuilderPath, ctx) {
|
|
5
5
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
6
6
|
server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
|
|
@@ -15,9 +15,7 @@ export function registerQuery(server, basePath, queryBuilderPath, ctx) {
|
|
|
15
15
|
validateAdvancedQuery(resource, query);
|
|
16
16
|
const rawResult = await repo.executeQuery(query);
|
|
17
17
|
const result = await applyHook(hooks?.afterQuery, rawResult, hookCtx);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
21
|
-
}, envelope);
|
|
18
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
19
|
+
await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
|
|
22
20
|
}));
|
|
23
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseListOptions } from '../../core/queryString.js';
|
|
2
|
-
import { applyHook, authorizeRequest,
|
|
2
|
+
import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
3
3
|
export function registerReadMany(server, basePath, ctx) {
|
|
4
4
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
5
5
|
server.registerRoute('GET', basePath, wrap(async (req, res) => {
|
|
@@ -10,9 +10,7 @@ export function registerReadMany(server, basePath, ctx) {
|
|
|
10
10
|
const listOptions = await applyHook(hooks?.beforeReadMany, parsedOptions, hookCtx);
|
|
11
11
|
const rawResult = await repo.getMany(listOptions);
|
|
12
12
|
const result = await applyHook(hooks?.afterReadMany, rawResult, hookCtx);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
16
|
-
}, envelope);
|
|
13
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
14
|
+
await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
|
|
17
15
|
}));
|
|
18
16
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseGetOneOptions } from '../../core/queryString.js';
|
|
2
2
|
import { NotFoundError } from '../../errors/NotFoundError.js';
|
|
3
3
|
import { applyHook, authorizeRequest, filterReadableFields, parseId, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
4
4
|
export function registerReadOne(server, basePath, ctx) {
|
|
@@ -10,11 +10,8 @@ export function registerReadOne(server, basePath, ctx) {
|
|
|
10
10
|
const hookCtx = { auth, resource, req };
|
|
11
11
|
if (hooks?.beforeReadOne)
|
|
12
12
|
await hooks.beforeReadOne(id, hookCtx);
|
|
13
|
-
const
|
|
14
|
-
const rawResult = await repo.getOne(id, {
|
|
15
|
-
fields: listOptions.fields,
|
|
16
|
-
include: listOptions.include
|
|
17
|
-
});
|
|
13
|
+
const { fields, include } = parseGetOneOptions(req.query, resource);
|
|
14
|
+
const rawResult = await repo.getOne(id, { fields, include });
|
|
18
15
|
if (!rawResult)
|
|
19
16
|
throw new NotFoundError();
|
|
20
17
|
const result = await applyHook(hooks?.afterReadOne, rawResult, hookCtx);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateAdvancedQuery } from '../../core/validation.js';
|
|
2
2
|
import { NotImplementedError } from '../../errors/NotImplementedError.js';
|
|
3
3
|
import { UnprocessableEntityError } from '../../errors/UnprocessableEntityError.js';
|
|
4
|
-
import { applyHook, authorizeRequest,
|
|
4
|
+
import { applyHook, authorizeRequest, filterWritableFields, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
5
5
|
export function registerUpdateMany(server, basePath, ctx) {
|
|
6
6
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
7
7
|
server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
|
|
@@ -22,12 +22,11 @@ export function registerUpdateMany(server, basePath, ctx) {
|
|
|
22
22
|
await hooks.beforeUpdateMany(query, filteredUpdate, hookCtx);
|
|
23
23
|
const rawResult = await repo.updateMany(query, filteredUpdate);
|
|
24
24
|
const result = await applyHook(hooks?.afterUpdateMany, rawResult, hookCtx);
|
|
25
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
25
26
|
await writeSuccess(res, 200, {
|
|
26
27
|
...result,
|
|
27
28
|
...(result.results
|
|
28
|
-
? {
|
|
29
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
30
|
-
}
|
|
29
|
+
? { results: result.results.map((r) => filterRecord(r)) }
|
|
31
30
|
: {})
|
|
32
31
|
}, envelope);
|
|
33
32
|
}));
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { type ListOptions, type ResourceDefinition } from '../core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
|
|
4
|
+
* Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
|
|
5
|
+
* work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
|
|
6
|
+
*
|
|
7
|
+
* @param query - The raw query-string object from the HTTP request.
|
|
8
|
+
* @param resource - The resource definition used for field and relation validation.
|
|
9
|
+
* @returns Typed projection options ready to pass to `repository.getOne()`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseGetOneOptions(query: Record<string, unknown>, resource: ResourceDefinition): Pick<ListOptions, 'fields' | 'include'>;
|
|
2
12
|
/**
|
|
3
13
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
4
14
|
*
|
package/dist/core/queryString.js
CHANGED
|
@@ -31,6 +31,29 @@ function parseCsv(value) {
|
|
|
31
31
|
.map((item) => item.trim())
|
|
32
32
|
.filter(Boolean);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
|
|
36
|
+
* Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
|
|
37
|
+
* work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
|
|
38
|
+
*
|
|
39
|
+
* @param query - The raw query-string object from the HTTP request.
|
|
40
|
+
* @param resource - The resource definition used for field and relation validation.
|
|
41
|
+
* @returns Typed projection options ready to pass to `repository.getOne()`.
|
|
42
|
+
*/
|
|
43
|
+
export function parseGetOneOptions(query, resource) {
|
|
44
|
+
const fields = parseCsv(query.fields);
|
|
45
|
+
const include = parseCsv(query.include);
|
|
46
|
+
if (fields) {
|
|
47
|
+
validateFields(resource, fields);
|
|
48
|
+
validateSelectableFields(resource, fields);
|
|
49
|
+
}
|
|
50
|
+
if (include)
|
|
51
|
+
validateIncludes(resource, include);
|
|
52
|
+
if (fields && include) {
|
|
53
|
+
throw new UnprocessableEntityError('Cannot use both ?fields= and ?include= in the same request.');
|
|
54
|
+
}
|
|
55
|
+
return { fields, include };
|
|
56
|
+
}
|
|
34
57
|
/**
|
|
35
58
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
36
59
|
*
|
package/dist/core/validation.js
CHANGED
|
@@ -58,9 +58,7 @@ export function validateId(value) {
|
|
|
58
58
|
* @returns Array of field name strings.
|
|
59
59
|
*/
|
|
60
60
|
export function getFieldNames(resource) {
|
|
61
|
-
return (resource.fields ?? []).map((
|
|
62
|
-
return field.name;
|
|
63
|
-
});
|
|
61
|
+
return (resource.fields ?? []).map((f) => f.name);
|
|
64
62
|
}
|
|
65
63
|
/**
|
|
66
64
|
* Throws {@link UnprocessableEntityError} when any of `fields` are not defined on the resource.
|
|
@@ -82,10 +80,8 @@ export function validateFields(resource, fields = []) {
|
|
|
82
80
|
* @param fields - Field names to check for selectability.
|
|
83
81
|
*/
|
|
84
82
|
export function validateSelectableFields(resource, fields) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
return field?.selectable === false;
|
|
88
|
-
});
|
|
83
|
+
const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
|
|
84
|
+
const nonSelectable = fields.filter((name) => fieldMap.get(name)?.selectable === false);
|
|
89
85
|
if (nonSelectable.length) {
|
|
90
86
|
throw new UnprocessableEntityError(`Field(s) not selectable: ${nonSelectable.join(', ')}.`);
|
|
91
87
|
}
|
|
@@ -96,10 +92,8 @@ export function validateSelectableFields(resource, fields) {
|
|
|
96
92
|
* @param fields - Field names to check for sortability.
|
|
97
93
|
*/
|
|
98
94
|
export function validateSortableFields(resource, fields) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
return field?.sortable === false;
|
|
102
|
-
});
|
|
95
|
+
const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
|
|
96
|
+
const nonSortable = fields.filter((name) => fieldMap.get(name)?.sortable === false);
|
|
103
97
|
if (nonSortable.length) {
|
|
104
98
|
throw new UnprocessableEntityError(`Field(s) not sortable: ${nonSortable.join(', ')}.`);
|
|
105
99
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from './HttpError.js';
|
|
2
|
+
/** Thrown when a write conflicts with an existing record — e.g. a duplicate unique field (HTTP 409). */
|
|
3
|
+
export class ConflictError extends HttpError {
|
|
4
|
+
constructor(message = 'Conflict', details) {
|
|
5
|
+
super(message, 409, details);
|
|
6
|
+
this.name = 'ConflictError';
|
|
7
|
+
}
|
|
8
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './core/types.js';
|
|
|
10
10
|
export * from './core/validation.js';
|
|
11
11
|
export * from '@edium/halifax-types';
|
|
12
12
|
export * from './errors/AuthenticationError.js';
|
|
13
|
+
export * from './errors/ConflictError.js';
|
|
13
14
|
export * from './errors/AuthorizationError.js';
|
|
14
15
|
export * from './errors/BadRequestError.js';
|
|
15
16
|
export * from './errors/HttpError.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './core/types.js';
|
|
|
10
10
|
export * from './core/validation.js';
|
|
11
11
|
export * from '@edium/halifax-types';
|
|
12
12
|
export * from './errors/AuthenticationError.js';
|
|
13
|
+
export * from './errors/ConflictError.js';
|
|
13
14
|
export * from './errors/AuthorizationError.js';
|
|
14
15
|
export * from './errors/BadRequestError.js';
|
|
15
16
|
export * from './errors/HttpError.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultCrudPermissions } from '../core/types.js';
|
|
2
|
-
import { mergeFieldDefinitions } from '../core/fields.js';
|
|
2
|
+
import { mergeFieldDefinitions, mergeRelationDefinitions, normalizeEnvelope } from '../core/fields.js';
|
|
3
3
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
4
|
// Exhaustive map from every FieldType to its JSON Schema type string.
|
|
5
5
|
// Adding a new FieldType causes a compile error here until the map is updated — no switch to edit.
|
|
@@ -21,9 +21,6 @@ function toPascalCase(routePrefix) {
|
|
|
21
21
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
22
22
|
.join('');
|
|
23
23
|
}
|
|
24
|
-
function normalizeEnvelope(value) {
|
|
25
|
-
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
26
|
-
}
|
|
27
24
|
function mergeFields(resource) {
|
|
28
25
|
const idField = resource.repository?.idField ?? 'id';
|
|
29
26
|
return mergeFieldDefinitions(resource).map((f) => ({
|
|
@@ -31,14 +28,6 @@ function mergeFields(resource) {
|
|
|
31
28
|
writable: f.name === idField ? f.writable === true : f.writable !== false
|
|
32
29
|
}));
|
|
33
30
|
}
|
|
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
31
|
// Wraps a schema under an envelope key if one is active.
|
|
43
32
|
function withEnvelope(schema, envelope) {
|
|
44
33
|
if (!envelope)
|
|
@@ -202,7 +191,7 @@ const sharedSchemas = {
|
|
|
202
191
|
' { "field": "published", "comparison": "=", "value": true },',
|
|
203
192
|
' { "field": "createdAt", "comparison": ">=", "value": "2024-01-01T00:00:00Z" }',
|
|
204
193
|
' ],',
|
|
205
|
-
' "orderBy": [{ "field": "createdAt", "
|
|
194
|
+
' "orderBy": [{ "field": "createdAt", "order": "DESC" }],',
|
|
206
195
|
' "limit": 20,',
|
|
207
196
|
' "offset": 0,',
|
|
208
197
|
' "fields": ["id", "title", "createdAt"],',
|
|
@@ -228,10 +217,10 @@ const sharedSchemas = {
|
|
|
228
217
|
description: 'Sort order. Multiple entries produce multi-column sorting.',
|
|
229
218
|
items: {
|
|
230
219
|
type: 'object',
|
|
231
|
-
required: ['field', '
|
|
220
|
+
required: ['field', 'order'],
|
|
232
221
|
properties: {
|
|
233
222
|
field: { type: 'string', description: 'Field name to sort by (must be sortable).' },
|
|
234
|
-
|
|
223
|
+
order: { type: 'string', enum: ['ASC', 'DESC'] }
|
|
235
224
|
}
|
|
236
225
|
}
|
|
237
226
|
},
|
|
@@ -240,7 +229,11 @@ const sharedSchemas = {
|
|
|
240
229
|
items: { type: 'string' },
|
|
241
230
|
description: 'Relation names to eagerly load (if the resource supports includes).'
|
|
242
231
|
},
|
|
243
|
-
distinct: {
|
|
232
|
+
distinct: {
|
|
233
|
+
type: 'array',
|
|
234
|
+
items: { type: 'string' },
|
|
235
|
+
description: 'Field names to de-duplicate results on (maps to SQL DISTINCT ON these columns).'
|
|
236
|
+
}
|
|
244
237
|
}
|
|
245
238
|
}
|
|
246
239
|
};
|
|
@@ -262,6 +255,7 @@ const badRequestError = errorRef('Bad Request — malformed query string, invali
|
|
|
262
255
|
const notFoundError = errorRef('Not Found — the record with the given ID does not exist.');
|
|
263
256
|
const unprocessableError = errorRef('Unprocessable Entity — request body contains unknown or non-writable fields.');
|
|
264
257
|
const notImplementedError = errorRef('Not Implemented — the underlying repository does not support this operation.');
|
|
258
|
+
const conflictError = errorRef('Conflict — the write was rejected because it would violate a unique constraint.');
|
|
265
259
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
266
260
|
export function generateOpenApiSpec(resources, options = {}) {
|
|
267
261
|
const globalEnvelope = normalizeEnvelope(options.envelope);
|
|
@@ -284,10 +278,17 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
284
278
|
: {})
|
|
285
279
|
}
|
|
286
280
|
};
|
|
281
|
+
// Note: resources here are the *raw* definitions as passed by the caller — they have NOT
|
|
282
|
+
// been through crudRouter's `normalizeResource()`. That means `mergeFields` and
|
|
283
|
+
// `mergeRelationDefinitions` below re-derive the same merged views that the router already
|
|
284
|
+
// computed at startup. This is intentional: the spec generator is a standalone function
|
|
285
|
+
// (called outside the router for static generation tooling), so it can't rely on the
|
|
286
|
+
// router's normalized state. If crudRouter ever caches normalized resources, pass them
|
|
287
|
+
// here instead to avoid the duplicate merge work.
|
|
287
288
|
for (const resource of resources) {
|
|
288
289
|
const permissions = { ...defaultCrudPermissions, ...resource.permissions };
|
|
289
290
|
const fields = mergeFields(resource);
|
|
290
|
-
const relations =
|
|
291
|
+
const relations = mergeRelationDefinitions(resource);
|
|
291
292
|
const idField = resource.repository?.idField ?? 'id';
|
|
292
293
|
const schemaBase = toPascalCase(resource.routePrefix);
|
|
293
294
|
const tag = resource.name ?? schemaBase;
|
|
@@ -458,6 +459,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
458
459
|
content: { 'application/json': { schema: { oneOf: [singleResponse, arrayResponse] } } }
|
|
459
460
|
},
|
|
460
461
|
'400': badRequestError,
|
|
462
|
+
'409': conflictError,
|
|
461
463
|
'422': unprocessableError,
|
|
462
464
|
...commonErrors,
|
|
463
465
|
...writeErrors
|
|
@@ -500,10 +502,10 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
500
502
|
type: 'array',
|
|
501
503
|
items: {
|
|
502
504
|
type: 'object',
|
|
503
|
-
required: ['field', '
|
|
505
|
+
required: ['field', 'order'],
|
|
504
506
|
properties: {
|
|
505
507
|
field: { type: 'string' },
|
|
506
|
-
|
|
508
|
+
order: { type: 'string', enum: ['ASC', 'DESC'] }
|
|
507
509
|
}
|
|
508
510
|
}
|
|
509
511
|
}
|
|
@@ -538,6 +540,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
538
540
|
}
|
|
539
541
|
},
|
|
540
542
|
'400': badRequestError,
|
|
543
|
+
'409': conflictError,
|
|
541
544
|
'422': unprocessableError,
|
|
542
545
|
'501': notImplementedError,
|
|
543
546
|
...commonErrors,
|
|
@@ -697,6 +700,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
697
700
|
},
|
|
698
701
|
'400': badRequestError,
|
|
699
702
|
'404': notFoundError,
|
|
703
|
+
'409': conflictError,
|
|
700
704
|
'422': unprocessableError,
|
|
701
705
|
...commonErrors,
|
|
702
706
|
...writeErrors
|
|
@@ -733,6 +737,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
733
737
|
}
|
|
734
738
|
},
|
|
735
739
|
'400': badRequestError,
|
|
740
|
+
'409': conflictError,
|
|
736
741
|
'422': unprocessableError,
|
|
737
742
|
'501': notImplementedError,
|
|
738
743
|
...commonErrors,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edium/halifax",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
],
|
|
167
167
|
"dependencies": {
|
|
168
168
|
"uuid": "^14.0.0",
|
|
169
|
-
"@edium/halifax-types": "2.
|
|
169
|
+
"@edium/halifax-types": "2.3.0"
|
|
170
170
|
},
|
|
171
171
|
"scripts": {
|
|
172
172
|
"build": "rm -rf dist && tsc --build && tsc-alias",
|