@edium/halifax 2.2.3 → 2.4.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 +202 -0
- package/README.md +6 -3
- 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_GRAPHQL.md +352 -0
- package/README_INTERFACES.md +19 -14
- package/README_MULTITENANCY.md +87 -0
- package/README_OPENAPI.md +9 -9
- package/README_REPO_ADAPTERS.md +10 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
- package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
- 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.d.ts +38 -0
- package/dist/core/crudRouter.js +55 -21
- package/dist/core/fields.d.ts +11 -1
- package/dist/core/fields.js +19 -0
- package/dist/core/handlerUtils.d.ts +7 -1
- package/dist/core/handlerUtils.js +15 -11
- package/dist/core/handlers/create.js +4 -3
- package/dist/core/handlers/deleteMany.js +1 -1
- package/dist/core/handlers/deleteOne.js +1 -1
- package/dist/core/handlers/query.js +4 -6
- package/dist/core/handlers/readMany.js +4 -6
- package/dist/core/handlers/readOne.js +4 -7
- package/dist/core/handlers/updateMany.js +4 -5
- package/dist/core/handlers/updateOne.js +1 -1
- package/dist/core/handlers/upsertOne.js +1 -1
- package/dist/core/queryString.d.ts +10 -0
- package/dist/core/queryString.js +23 -0
- package/dist/core/types.d.ts +22 -0
- package/dist/core/validation.js +5 -11
- package/dist/graphql/graphiql.d.ts +5 -0
- package/dist/graphql/graphiql.js +29 -0
- package/dist/graphql/index.d.ts +4 -0
- package/dist/graphql/index.js +3 -0
- package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
- package/dist/graphql/registerGraphqlRoute.js +79 -0
- package/dist/graphql/scalars.d.ts +6 -0
- package/dist/graphql/scalars.js +32 -0
- package/dist/graphql/schema.d.ts +3 -0
- package/dist/graphql/schema.js +635 -0
- package/dist/graphql/types.d.ts +48 -0
- package/dist/graphql/types.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/openapi/specGenerator.js +19 -19
- package/package.json +9 -3
package/dist/core/fields.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
|
|
1
|
+
import type { FieldDefinition, RelationDefinition, ResourceDefinition } from '../core/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Merges a resource's field schema: repository fields are the base, and
|
|
4
4
|
* `resource.fields` entries are applied as sparse overrides (by name).
|
|
@@ -6,3 +6,13 @@ import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
|
|
|
6
6
|
* the flags they care about on top of this.
|
|
7
7
|
*/
|
|
8
8
|
export declare function mergeFieldDefinitions(resource: ResourceDefinition): FieldDefinition[];
|
|
9
|
+
/**
|
|
10
|
+
* Merges a resource's relation schema: repository relations are the base, and
|
|
11
|
+
* `resource.relations` entries are applied as sparse overrides (by name).
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeRelationDefinitions(resource: ResourceDefinition): RelationDefinition[];
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping;
|
|
16
|
+
* `null`, `undefined`, and `''` all mean "no envelope".
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeEnvelope(value: string | null | undefined): string | null;
|
package/dist/core/fields.js
CHANGED
|
@@ -12,3 +12,22 @@ export function mergeFieldDefinitions(resource) {
|
|
|
12
12
|
byName.set(f.name, { ...byName.get(f.name), ...f });
|
|
13
13
|
return [...byName.values()];
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Merges a resource's relation schema: repository relations are the base, and
|
|
17
|
+
* `resource.relations` entries are applied as sparse overrides (by name).
|
|
18
|
+
*/
|
|
19
|
+
export function mergeRelationDefinitions(resource) {
|
|
20
|
+
const byName = new Map();
|
|
21
|
+
for (const r of resource.repository?.relations ?? [])
|
|
22
|
+
byName.set(r.name, r);
|
|
23
|
+
for (const r of resource.relations ?? [])
|
|
24
|
+
byName.set(r.name, r);
|
|
25
|
+
return [...byName.values()];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping;
|
|
29
|
+
* `null`, `undefined`, and `''` all mean "no envelope".
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeEnvelope(value) {
|
|
32
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
33
|
+
}
|
|
@@ -41,6 +41,12 @@ export declare function filterWritableFields(resource: ResourceDefinition, data:
|
|
|
41
41
|
* Fast-path returns the record unchanged when no fields carry read restrictions.
|
|
42
42
|
*/
|
|
43
43
|
export declare function filterReadableFields(resource: ResourceDefinition, record: Record<string, unknown>, auth?: AuthContext): Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Returns a reusable filter function that strips read-restricted fields.
|
|
46
|
+
* Build this once per request (outside a `.map()` loop) so the fieldMap and
|
|
47
|
+
* userRoles Set are not reconstructed for every record in a bulk response.
|
|
48
|
+
*/
|
|
49
|
+
export declare function makeReadableFieldFilter(resource: ResourceDefinition, auth?: AuthContext): (record: Record<string, unknown>) => Record<string, unknown>;
|
|
44
50
|
/**
|
|
45
51
|
* Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
|
|
46
52
|
*/
|
|
@@ -66,5 +72,5 @@ export interface RouteHandlerContext {
|
|
|
66
72
|
authStrategy: AuthStrategy;
|
|
67
73
|
envelope: string | null;
|
|
68
74
|
hooks: CrudHooks<Record<string, unknown>, Record<string, unknown>, Record<string, unknown>> | undefined;
|
|
69
|
-
resolveRepo: (req: HttpRequest, auth: AuthContext) => Promise<Repository>;
|
|
75
|
+
resolveRepo: (req: HttpRequest, auth: AuthContext, action: CrudAction) => Promise<Repository>;
|
|
70
76
|
}
|
|
@@ -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';
|
|
@@ -63,9 +64,9 @@ export function writeSuccess(res, status, body, envelope) {
|
|
|
63
64
|
*/
|
|
64
65
|
export function parseId(raw) {
|
|
65
66
|
validateId(raw);
|
|
66
|
-
if (
|
|
67
|
+
if (isValidUuid(raw) || isValidObjectId(raw))
|
|
67
68
|
return raw;
|
|
68
|
-
return
|
|
69
|
+
return parseInt(raw, 10);
|
|
69
70
|
}
|
|
70
71
|
/**
|
|
71
72
|
* Strips non-writable fields from a request body and rejects unknown fields with a 422.
|
|
@@ -96,12 +97,20 @@ export function filterWritableFields(resource, data, auth) {
|
|
|
96
97
|
* Fast-path returns the record unchanged when no fields carry read restrictions.
|
|
97
98
|
*/
|
|
98
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) {
|
|
99
108
|
const fields = resource.fields ?? [];
|
|
100
109
|
if (!fields.some((f) => (f.readRoles?.length ?? 0) > 0))
|
|
101
|
-
return
|
|
110
|
+
return (r) => r;
|
|
102
111
|
const fieldMap = new Map(fields.map((f) => [f.name, f]));
|
|
103
112
|
const userRoles = new Set([...(auth?.roles ?? []), ...(auth?.permissions ?? [])]);
|
|
104
|
-
return Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
113
|
+
return (record) => Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
105
114
|
const field = fieldMap.get(key);
|
|
106
115
|
if (!field?.readRoles?.length)
|
|
107
116
|
return true;
|
|
@@ -126,13 +135,8 @@ export async function authorizeRequest(req, resource, action, authStrategy) {
|
|
|
126
135
|
throw new AuthorizationError();
|
|
127
136
|
return auth;
|
|
128
137
|
}
|
|
129
|
-
if (requiredPermissions
|
|
130
|
-
|
|
131
|
-
const roles = new Set(auth.roles ?? []);
|
|
132
|
-
const allowed = requiredPermissions.some((permission) => permissions.has(permission) || roles.has(permission));
|
|
133
|
-
if (!allowed)
|
|
134
|
-
throw new AuthorizationError();
|
|
135
|
-
}
|
|
138
|
+
if (!checkRequiredPermissions(auth, requiredPermissions))
|
|
139
|
+
throw new AuthorizationError();
|
|
136
140
|
return auth;
|
|
137
141
|
}
|
|
138
142
|
/**
|
|
@@ -1,9 +1,9 @@
|
|
|
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) => {
|
|
5
5
|
const auth = await authorizeRequest(req, resource, 'create', authStrategy);
|
|
6
|
-
const repo = await resolveRepo(req, auth);
|
|
6
|
+
const repo = await resolveRepo(req, auth, 'create');
|
|
7
7
|
const idempotencyKey = getHeaderValue(req, 'idempotency-key');
|
|
8
8
|
const createOptions = idempotencyKey ? { idempotencyKey } : undefined;
|
|
9
9
|
const hookCtx = { auth, resource, req };
|
|
@@ -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
|
}
|
|
@@ -6,7 +6,7 @@ export function registerDeleteMany(server, basePath, ctx) {
|
|
|
6
6
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
7
7
|
server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
|
|
8
8
|
const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
|
|
9
|
-
const repo = await resolveRepo(req, auth);
|
|
9
|
+
const repo = await resolveRepo(req, auth, 'deleteMany');
|
|
10
10
|
if (!repo.deleteMany)
|
|
11
11
|
throw new NotImplementedError('This resource does not support deleteMany.');
|
|
12
12
|
const hookCtx = { auth, resource, req };
|
|
@@ -4,7 +4,7 @@ export function registerDeleteOne(server, basePath, ctx) {
|
|
|
4
4
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
5
5
|
server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
|
|
6
6
|
const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
|
|
7
|
-
const repo = await resolveRepo(req, auth);
|
|
7
|
+
const repo = await resolveRepo(req, auth, 'deleteOne');
|
|
8
8
|
const id = parseId(req.params['id']);
|
|
9
9
|
const hookCtx = { auth, resource, req };
|
|
10
10
|
if (hooks?.beforeDeleteOne)
|
|
@@ -1,11 +1,11 @@
|
|
|
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) => {
|
|
7
7
|
const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
|
|
8
|
-
const repo = await resolveRepo(req, auth);
|
|
8
|
+
const repo = await resolveRepo(req, auth, 'readManyWithQueryBuilder');
|
|
9
9
|
if (!repo.executeQuery)
|
|
10
10
|
throw new NotImplementedError('This resource does not support the query builder.');
|
|
11
11
|
const hookCtx = { auth, resource, req };
|
|
@@ -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,18 +1,16 @@
|
|
|
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) => {
|
|
6
6
|
const auth = await authorizeRequest(req, resource, 'readMany', authStrategy);
|
|
7
|
-
const repo = await resolveRepo(req, auth);
|
|
7
|
+
const repo = await resolveRepo(req, auth, 'readMany');
|
|
8
8
|
const hookCtx = { auth, resource, req };
|
|
9
9
|
const parsedOptions = parseListOptions(req.query, resource);
|
|
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,20 +1,17 @@
|
|
|
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) {
|
|
5
5
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
6
6
|
server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
|
|
7
7
|
const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
|
|
8
|
-
const repo = await resolveRepo(req, auth);
|
|
8
|
+
const repo = await resolveRepo(req, auth, 'readOne');
|
|
9
9
|
const id = parseId(req.params['id']);
|
|
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,12 +1,12 @@
|
|
|
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) => {
|
|
8
8
|
const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
|
|
9
|
-
const repo = await resolveRepo(req, auth);
|
|
9
|
+
const repo = await resolveRepo(req, auth, 'updateMany');
|
|
10
10
|
if (!repo.updateMany)
|
|
11
11
|
throw new NotImplementedError('This resource does not support updateMany.');
|
|
12
12
|
const hookCtx = { auth, resource, req };
|
|
@@ -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
|
}));
|
|
@@ -4,7 +4,7 @@ export function registerUpdateOne(server, basePath, ctx) {
|
|
|
4
4
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
5
5
|
server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
|
|
6
6
|
const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
|
|
7
|
-
const repo = await resolveRepo(req, auth);
|
|
7
|
+
const repo = await resolveRepo(req, auth, 'updateOne');
|
|
8
8
|
const id = parseId(req.params['id']);
|
|
9
9
|
const hookCtx = { auth, resource, req };
|
|
10
10
|
const rawBody = filterWritableFields(resource, (req.body ?? {}), auth);
|
|
@@ -4,7 +4,7 @@ export function registerUpsertOne(server, basePath, ctx) {
|
|
|
4
4
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
5
5
|
server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
|
|
6
6
|
const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
|
|
7
|
-
const repo = await resolveRepo(req, auth);
|
|
7
|
+
const repo = await resolveRepo(req, auth, 'upsertOne');
|
|
8
8
|
if (!repo.upsertOne)
|
|
9
9
|
throw new NotImplementedError('This resource does not support upsert.');
|
|
10
10
|
const id = parseId(req.params['id']);
|
|
@@ -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/types.d.ts
CHANGED
|
@@ -389,6 +389,28 @@ export interface ResourceDefinition<TRecord = unknown, TCreate = Partial<TRecord
|
|
|
389
389
|
* `false` to explicitly disable caching for this resource even when a default is configured.
|
|
390
390
|
*/
|
|
391
391
|
cache?: ResourceCacheConfig | false;
|
|
392
|
+
/**
|
|
393
|
+
* Controls whether this resource is exposed through the GraphQL endpoint.
|
|
394
|
+
* Set to `false` to exclude this resource from the generated GraphQL schema entirely.
|
|
395
|
+
* Defaults to `true` when GraphQL is enabled on the API.
|
|
396
|
+
*/
|
|
397
|
+
graphql?: boolean;
|
|
398
|
+
/**
|
|
399
|
+
* Roles or permission slugs whose holders may bypass tenant scoping for **read** operations
|
|
400
|
+
* on this resource, allowing them to see records across all tenants. Any single match in
|
|
401
|
+
* `auth.roles` or `auth.permissions` grants the bypass.
|
|
402
|
+
*
|
|
403
|
+
* Bypass callers receive all records on reads. To narrow to a single tenant they use the
|
|
404
|
+
* standard filter mechanism (`?companyId=42` on REST, `filter: { companyId: 42 }` in
|
|
405
|
+
* GraphQL) — the tenant field is just another filterable column from their perspective.
|
|
406
|
+
*
|
|
407
|
+
* Write operations (create / update / delete) are **never** bypassed — tenant value on writes
|
|
408
|
+
* always comes from `resolveId` (the auth token), not from the bypass path.
|
|
409
|
+
*
|
|
410
|
+
* Overrides {@link TenantOptions.bypassRoles} for this resource. Set to `[]` to disable
|
|
411
|
+
* bypass on this resource even when a global `bypassRoles` is configured.
|
|
412
|
+
*/
|
|
413
|
+
bypassTenantRoles?: string[];
|
|
392
414
|
/**
|
|
393
415
|
* Wrap every success response body for this resource under a single key
|
|
394
416
|
* (e.g. `'data'` →
|
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,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an HTML page that renders the GraphiQL IDE, loading assets from unpkg.com CDN.
|
|
3
|
+
* The GraphQL endpoint URL is embedded so GraphiQL points at the correct server path.
|
|
4
|
+
*/
|
|
5
|
+
export declare function generateGraphiQLHtml(graphqlPath: string, title: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an HTML page that renders the GraphiQL IDE, loading assets from unpkg.com CDN.
|
|
3
|
+
* The GraphQL endpoint URL is embedded so GraphiQL points at the correct server path.
|
|
4
|
+
*/
|
|
5
|
+
export function generateGraphiQLHtml(graphqlPath, title) {
|
|
6
|
+
const escapedPath = JSON.stringify(graphqlPath);
|
|
7
|
+
return `<!DOCTYPE html>
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="utf-8" />
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
12
|
+
<title>${title}</title>
|
|
13
|
+
<link href="https://unpkg.com/graphiql/graphiql.min.css" rel="stylesheet" />
|
|
14
|
+
<style>body { margin: 0; }</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="graphiql" style="height:100vh;"></div>
|
|
18
|
+
<script crossorigin src="https://unpkg.com/react/umd/react.production.min.js"></script>
|
|
19
|
+
<script crossorigin src="https://unpkg.com/react-dom/umd/react-dom.production.min.js"></script>
|
|
20
|
+
<script src="https://unpkg.com/graphiql/graphiql.min.js" type="application/javascript"></script>
|
|
21
|
+
<script>
|
|
22
|
+
var fetcher = GraphiQL.createFetcher({ url: ${escapedPath} });
|
|
23
|
+
ReactDOM.createRoot(document.getElementById('graphiql')).render(
|
|
24
|
+
React.createElement(GraphiQL, { fetcher: fetcher })
|
|
25
|
+
);
|
|
26
|
+
</script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GraphQLOptions, GraphQLResourceContext } from './types.js';
|
|
2
|
+
import type { AuthStrategy } from '../auth/strategies/types.js';
|
|
3
|
+
import type { HttpServer } from '../core/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Registers the `POST /graphql` execution endpoint and optionally the `GET /graphql`
|
|
6
|
+
* GraphiQL IDE page on the given HTTP server.
|
|
7
|
+
*
|
|
8
|
+
* The schema is built once at registration time from the provided resource contexts.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerGraphqlRoute(server: HttpServer, contexts: GraphQLResourceContext[], options: GraphQLOptions, authStrategy: AuthStrategy): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { graphql, parse, validate } from 'graphql';
|
|
2
|
+
import { buildGraphQLSchema } from './schema.js';
|
|
3
|
+
import { generateGraphiQLHtml } from './graphiql.js';
|
|
4
|
+
import { sendError } from '../core/handlerUtils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Registers the `POST /graphql` execution endpoint and optionally the `GET /graphql`
|
|
7
|
+
* GraphiQL IDE page on the given HTTP server.
|
|
8
|
+
*
|
|
9
|
+
* The schema is built once at registration time from the provided resource contexts.
|
|
10
|
+
*/
|
|
11
|
+
export function registerGraphqlRoute(server, contexts, options, authStrategy) {
|
|
12
|
+
if (options.enabled === false)
|
|
13
|
+
return;
|
|
14
|
+
const path = options.path ?? '/graphql';
|
|
15
|
+
const graphiqlEnabled = options.graphiql !== false;
|
|
16
|
+
const requireAuth = options.requireAuth === true;
|
|
17
|
+
const title = options.title ?? 'Halifax GraphQL';
|
|
18
|
+
const schema = buildGraphQLSchema(contexts);
|
|
19
|
+
const graphiqlHtml = graphiqlEnabled ? generateGraphiQLHtml(path, title) : null;
|
|
20
|
+
// ─── POST /graphql — execution endpoint ─────────────────────────────────
|
|
21
|
+
server.registerRoute('POST', path, async (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
if (requireAuth)
|
|
24
|
+
await authStrategy.authenticate(req);
|
|
25
|
+
const body = (req.body ?? {});
|
|
26
|
+
const source = body.query ?? '';
|
|
27
|
+
if (!source.trim()) {
|
|
28
|
+
await res.status(400).json({
|
|
29
|
+
errors: [{ message: 'GraphQL request must include a query.' }]
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Parse and validate before executing so syntax errors surface cleanly.
|
|
34
|
+
let document;
|
|
35
|
+
try {
|
|
36
|
+
document = parse(source);
|
|
37
|
+
}
|
|
38
|
+
catch (syntaxError) {
|
|
39
|
+
await res.status(400).json({
|
|
40
|
+
errors: [
|
|
41
|
+
{ message: syntaxError instanceof Error ? syntaxError.message : 'Syntax error.' }
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const validationErrors = validate(schema, document);
|
|
47
|
+
if (validationErrors.length) {
|
|
48
|
+
await res.status(400).json({ errors: validationErrors.map((e) => ({ message: e.message })) });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const result = await graphql({
|
|
52
|
+
schema,
|
|
53
|
+
source,
|
|
54
|
+
variableValues: body.variables,
|
|
55
|
+
operationName: body.operationName,
|
|
56
|
+
contextValue: { req }
|
|
57
|
+
});
|
|
58
|
+
res.setHeader?.('Content-Type', 'application/json');
|
|
59
|
+
await res.status(200).json(result);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await sendError(error, res);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// ─── GET /graphql — GraphiQL IDE ─────────────────────────────────────────
|
|
66
|
+
if (graphiqlEnabled && graphiqlHtml) {
|
|
67
|
+
server.registerRoute('GET', path, async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
if (requireAuth)
|
|
70
|
+
await authStrategy.authenticate(req);
|
|
71
|
+
res.setHeader?.('Content-Type', 'text/html; charset=utf-8');
|
|
72
|
+
res.send?.(graphiqlHtml);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await sendError(error, res);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { GraphQLScalarType } from 'graphql';
|
|
2
|
+
/**
|
|
3
|
+
* Custom `JSON` scalar that accepts any JSON-serializable value.
|
|
4
|
+
* Used for filter `value1`/`value2` fields in QueryFilterInput and for `object` field types.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GraphQLJSON: GraphQLScalarType<unknown, unknown>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { GraphQLScalarType, Kind } from 'graphql';
|
|
2
|
+
function parseLiteralToValue(ast) {
|
|
3
|
+
switch (ast.kind) {
|
|
4
|
+
case Kind.STRING:
|
|
5
|
+
return ast.value;
|
|
6
|
+
case Kind.BOOLEAN:
|
|
7
|
+
return ast.value;
|
|
8
|
+
case Kind.INT:
|
|
9
|
+
return parseInt(ast.value, 10);
|
|
10
|
+
case Kind.FLOAT:
|
|
11
|
+
return parseFloat(ast.value);
|
|
12
|
+
case Kind.NULL:
|
|
13
|
+
return null;
|
|
14
|
+
case Kind.LIST:
|
|
15
|
+
return ast.values.map(parseLiteralToValue);
|
|
16
|
+
case Kind.OBJECT:
|
|
17
|
+
return Object.fromEntries(ast.fields.map((f) => [f.name.value, parseLiteralToValue(f.value)]));
|
|
18
|
+
default:
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Custom `JSON` scalar that accepts any JSON-serializable value.
|
|
24
|
+
* Used for filter `value1`/`value2` fields in QueryFilterInput and for `object` field types.
|
|
25
|
+
*/
|
|
26
|
+
export const GraphQLJSON = new GraphQLScalarType({
|
|
27
|
+
name: 'JSON',
|
|
28
|
+
description: 'Arbitrary JSON value (string, number, boolean, null, array, or object).',
|
|
29
|
+
serialize: (value) => value,
|
|
30
|
+
parseValue: (value) => value,
|
|
31
|
+
parseLiteral: parseLiteralToValue
|
|
32
|
+
});
|