@martel/calyx 1.9.0 → 1.10.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 +7 -0
- package/package.json +1 -1
- package/src/graphql/graphql.module.ts +232 -38
- package/src/http/application.ts +104 -4
- package/tests/graphql.test.ts +181 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.10.0](https://github.com/bmartel/calyx/compare/v1.9.0...v1.10.0) (2026-07-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **graphql:** integrate context-aware enhancers pipeline and native ws subscriptions ([24e86f2](https://github.com/bmartel/calyx/commit/24e86f2c180a1b3b179e2acb9a198f514b204f82))
|
|
7
|
+
|
|
1
8
|
# [1.9.0](https://github.com/bmartel/calyx/compare/v1.8.0...v1.9.0) (2026-07-01)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -30,6 +30,39 @@ export class GraphQLModule {
|
|
|
30
30
|
const typeMap = new Map<any, any>();
|
|
31
31
|
const inputTypeMap = new Map<any, any>();
|
|
32
32
|
|
|
33
|
+
function findModuleClass(resolverClass: any): any {
|
|
34
|
+
for (const [moduleClass, record] of container.getModules().entries()) {
|
|
35
|
+
if (record.providers.has(resolverClass) || record.controllers.has(resolverClass)) {
|
|
36
|
+
return moduleClass;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compileLifecycleItems(moduleClass: any, items: any[]): any[] {
|
|
43
|
+
return items.map((item) => {
|
|
44
|
+
if (typeof item === 'function') {
|
|
45
|
+
const isClass =
|
|
46
|
+
item.prototype &&
|
|
47
|
+
(typeof item.prototype.canActivate === 'function' ||
|
|
48
|
+
typeof item.prototype.intercept === 'function' ||
|
|
49
|
+
typeof item.prototype.transform === 'function' ||
|
|
50
|
+
typeof item.prototype.catch === 'function');
|
|
51
|
+
if (!isClass) {
|
|
52
|
+
return { isReqScoped: false, token: item, instance: item };
|
|
53
|
+
}
|
|
54
|
+
let instance: any;
|
|
55
|
+
try {
|
|
56
|
+
instance = container.resolveTokenInModuleContext(moduleClass, item);
|
|
57
|
+
} catch {
|
|
58
|
+
instance = new item();
|
|
59
|
+
}
|
|
60
|
+
return { isReqScoped: false, token: item, instance };
|
|
61
|
+
}
|
|
62
|
+
return { isReqScoped: false, token: item.constructor, instance: item };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
function buildFieldsConfig(typeClass: any, isInput: boolean): any {
|
|
34
67
|
const fieldsMetadata: { propertyKey: string; typeFunc?: any; options?: any }[] =
|
|
35
68
|
Reflect.getMetadata('calyx:fields', typeClass) || [];
|
|
@@ -139,6 +172,7 @@ export class GraphQLModule {
|
|
|
139
172
|
|
|
140
173
|
for (const resolverInstance of resolverInstances) {
|
|
141
174
|
const resolverClass = resolverInstance.constructor;
|
|
175
|
+
const moduleClass = findModuleClass(resolverClass);
|
|
142
176
|
|
|
143
177
|
// Compile helper for queries, mutations, subscriptions
|
|
144
178
|
const compileField = (fieldMeta: { propertyKey: string | symbol; typeFunc?: any; options?: any }, list: any) => {
|
|
@@ -177,29 +211,110 @@ export class GraphQLModule {
|
|
|
177
211
|
}
|
|
178
212
|
}
|
|
179
213
|
|
|
214
|
+
// Enhancers metadata resolution
|
|
215
|
+
const classGuards = Reflect.getMetadata('calyx:guards', resolverClass) || [];
|
|
216
|
+
const methodGuards = Reflect.getMetadata('calyx:guards', resolverClass.prototype, fieldMeta.propertyKey) || [];
|
|
217
|
+
const compiledGuards = compileLifecycleItems(moduleClass, [...classGuards, ...methodGuards]);
|
|
218
|
+
|
|
219
|
+
const classInterceptors = Reflect.getMetadata('calyx:interceptors', resolverClass) || [];
|
|
220
|
+
const methodInterceptors = Reflect.getMetadata('calyx:interceptors', resolverClass.prototype, fieldMeta.propertyKey) || [];
|
|
221
|
+
const compiledInterceptors = compileLifecycleItems(moduleClass, [...classInterceptors, ...methodInterceptors]);
|
|
222
|
+
|
|
223
|
+
const classFilters = Reflect.getMetadata('calyx:filters', resolverClass) || [];
|
|
224
|
+
const methodFilters = Reflect.getMetadata('calyx:filters', resolverClass.prototype, fieldMeta.propertyKey) || [];
|
|
225
|
+
const compiledFilters = compileLifecycleItems(moduleClass, [...classFilters, ...methodFilters]);
|
|
226
|
+
|
|
227
|
+
const classPipes = Reflect.getMetadata('calyx:pipes', resolverClass) || [];
|
|
228
|
+
const methodPipes = Reflect.getMetadata('calyx:pipes', resolverClass.prototype, fieldMeta.propertyKey) || [];
|
|
229
|
+
const compiledPipes = compileLifecycleItems(moduleClass, [...classPipes, ...methodPipes]);
|
|
230
|
+
|
|
180
231
|
const fieldName = fieldMeta.options?.name || String(fieldMeta.propertyKey);
|
|
181
232
|
|
|
182
|
-
const resolveFn = async (parent: any, args: any, context: any) => {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
233
|
+
const resolveFn = async (parent: any, args: any, context: any, info: any) => {
|
|
234
|
+
const req = context?.req;
|
|
235
|
+
const { CalyxExecutionContext } = await import('../lifecycle/context.ts');
|
|
236
|
+
const execContext = new CalyxExecutionContext(req, null, resolverClass, resolverInstance[fieldMeta.propertyKey]);
|
|
237
|
+
(execContext as any).type = 'graphql';
|
|
238
|
+
(execContext as any).data = args;
|
|
239
|
+
|
|
240
|
+
const gqlArgs = [parent, args, context, info];
|
|
241
|
+
execContext.getArgs = () => gqlArgs as any;
|
|
242
|
+
execContext.getArgByIndex = (index: number) => gqlArgs[index];
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// 1. Guards
|
|
246
|
+
for (const guard of compiledGuards) {
|
|
247
|
+
const canActivate = guard.instance.canActivate(execContext);
|
|
248
|
+
const resolved = canActivate instanceof Promise ? await canActivate : canActivate;
|
|
249
|
+
if (!resolved) {
|
|
250
|
+
const { HttpException } = await import('../http/exceptions.ts');
|
|
251
|
+
throw new HttpException('Forbidden resource', 403);
|
|
191
252
|
}
|
|
192
|
-
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 2. Pipes
|
|
256
|
+
let processedArgs = args;
|
|
257
|
+
for (const pipe of compiledPipes) {
|
|
258
|
+
const transformed = pipe.instance.transform(processedArgs, {
|
|
259
|
+
type: 'custom',
|
|
260
|
+
metatype: undefined,
|
|
261
|
+
data: undefined,
|
|
262
|
+
});
|
|
263
|
+
processedArgs = transformed instanceof Promise ? await transformed : transformed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const executeHandler = async () => {
|
|
267
|
+
const params: any[] = [];
|
|
268
|
+
for (const arg of argsMetadata) {
|
|
269
|
+
const paramType = paramTypes[arg.parameterIndex] || String;
|
|
270
|
+
if (!arg.name && typeof paramType === 'function' && Reflect.hasMetadata('calyx:args_type', paramType)) {
|
|
271
|
+
const argInst = new paramType();
|
|
272
|
+
const fieldsMetadata = Reflect.getMetadata('calyx:fields', paramType) || [];
|
|
273
|
+
for (const f of fieldsMetadata) {
|
|
274
|
+
argInst[f.propertyKey] = processedArgs[f.propertyKey];
|
|
275
|
+
}
|
|
276
|
+
params[arg.parameterIndex] = argInst;
|
|
277
|
+
} else {
|
|
278
|
+
const argName = arg.name || 'args';
|
|
279
|
+
params[arg.parameterIndex] = processedArgs[argName];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, fieldMeta.propertyKey) || [];
|
|
283
|
+
for (const idx of parentParams) {
|
|
284
|
+
params[idx] = parent;
|
|
285
|
+
}
|
|
286
|
+
return resolverInstance[fieldMeta.propertyKey](...params);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// 3. Interceptors
|
|
290
|
+
if (compiledInterceptors.length > 0) {
|
|
291
|
+
let interceptorIndex = 0;
|
|
292
|
+
const nextHandler = async (): Promise<any> => {
|
|
293
|
+
if (interceptorIndex < compiledInterceptors.length) {
|
|
294
|
+
const interceptor = compiledInterceptors[interceptorIndex++];
|
|
295
|
+
return interceptor.instance.intercept(execContext, { handle: () => nextHandler() });
|
|
296
|
+
}
|
|
297
|
+
return executeHandler();
|
|
298
|
+
};
|
|
299
|
+
return await nextHandler();
|
|
193
300
|
} else {
|
|
194
|
-
|
|
195
|
-
params[arg.parameterIndex] = args[argName];
|
|
301
|
+
return await executeHandler();
|
|
196
302
|
}
|
|
303
|
+
} catch (err: any) {
|
|
304
|
+
// 4. Exception Filters
|
|
305
|
+
if (compiledFilters.length > 0) {
|
|
306
|
+
for (const filter of compiledFilters) {
|
|
307
|
+
const catchException = Reflect.getMetadata('calyx:catch', filter.token) || [];
|
|
308
|
+
if (catchException.length === 0 || catchException.some((exc: any) => err instanceof exc)) {
|
|
309
|
+
const filterResult = filter.instance.catch(err, execContext);
|
|
310
|
+
if (filterResult !== undefined) {
|
|
311
|
+
return filterResult;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw err;
|
|
197
317
|
}
|
|
198
|
-
const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, fieldMeta.propertyKey) || [];
|
|
199
|
-
for (const idx of parentParams) {
|
|
200
|
-
params[idx] = parent;
|
|
201
|
-
}
|
|
202
|
-
return resolverInstance[fieldMeta.propertyKey](...params);
|
|
203
318
|
};
|
|
204
319
|
|
|
205
320
|
list[fieldName] = {
|
|
@@ -232,13 +347,15 @@ export class GraphQLModule {
|
|
|
232
347
|
const { argsMetadata, paramTypes } = compileField(sub, subscriptionFields);
|
|
233
348
|
const subName = sub.options?.name || String(sub.propertyKey);
|
|
234
349
|
|
|
235
|
-
// Wrap with standard GraphQL subscribe / resolve
|
|
236
350
|
const originalResolve = subscriptionFields[subName].resolve;
|
|
237
351
|
subscriptionFields[subName].subscribe = originalResolve;
|
|
238
352
|
subscriptionFields[subName].resolve = (payload: any) => {
|
|
239
353
|
if (sub.options?.resolve) {
|
|
240
354
|
return sub.options.resolve(payload);
|
|
241
355
|
}
|
|
356
|
+
if (payload && typeof payload === 'object' && subName in payload) {
|
|
357
|
+
return payload[subName];
|
|
358
|
+
}
|
|
242
359
|
return payload;
|
|
243
360
|
};
|
|
244
361
|
}
|
|
@@ -255,30 +372,107 @@ export class GraphQLModule {
|
|
|
255
372
|
for (const fieldRes of fieldResolvers) {
|
|
256
373
|
const fieldName = fieldRes.options?.name || String(fieldRes.propertyKey);
|
|
257
374
|
if (targetFields[fieldName]) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
375
|
+
// Resolve field enhancers resolution
|
|
376
|
+
const classGuards = Reflect.getMetadata('calyx:guards', resolverClass) || [];
|
|
377
|
+
const methodGuards = Reflect.getMetadata('calyx:guards', resolverClass.prototype, fieldRes.propertyKey) || [];
|
|
378
|
+
const compiledGuards = compileLifecycleItems(moduleClass, [...classGuards, ...methodGuards]);
|
|
379
|
+
|
|
380
|
+
const classInterceptors = Reflect.getMetadata('calyx:interceptors', resolverClass) || [];
|
|
381
|
+
const methodInterceptors = Reflect.getMetadata('calyx:interceptors', resolverClass.prototype, fieldRes.propertyKey) || [];
|
|
382
|
+
const compiledInterceptors = compileLifecycleItems(moduleClass, [...classInterceptors, ...methodInterceptors]);
|
|
383
|
+
|
|
384
|
+
const classFilters = Reflect.getMetadata('calyx:filters', resolverClass) || [];
|
|
385
|
+
const methodFilters = Reflect.getMetadata('calyx:filters', resolverClass.prototype, fieldRes.propertyKey) || [];
|
|
386
|
+
const compiledFilters = compileLifecycleItems(moduleClass, [...classFilters, ...methodFilters]);
|
|
387
|
+
|
|
388
|
+
const classPipes = Reflect.getMetadata('calyx:pipes', resolverClass) || [];
|
|
389
|
+
const methodPipes = Reflect.getMetadata('calyx:pipes', resolverClass.prototype, fieldRes.propertyKey) || [];
|
|
390
|
+
const compiledPipes = compileLifecycleItems(moduleClass, [...classPipes, ...methodPipes]);
|
|
391
|
+
|
|
392
|
+
targetFields[fieldName].resolve = async (parent: any, args: any, context: any, info: any) => {
|
|
393
|
+
const req = context?.req;
|
|
394
|
+
const { CalyxExecutionContext } = await import('../lifecycle/context.ts');
|
|
395
|
+
const execContext = new CalyxExecutionContext(req, null, resolverClass, resolverInstance[fieldRes.propertyKey]);
|
|
396
|
+
(execContext as any).type = 'graphql';
|
|
397
|
+
(execContext as any).data = args;
|
|
398
|
+
|
|
399
|
+
const gqlArgs = [parent, args, context, info];
|
|
400
|
+
execContext.getArgs = () => gqlArgs as any;
|
|
401
|
+
execContext.getArgByIndex = (index: number) => gqlArgs[index];
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
for (const guard of compiledGuards) {
|
|
405
|
+
const canActivate = guard.instance.canActivate(execContext);
|
|
406
|
+
const resolved = canActivate instanceof Promise ? await canActivate : canActivate;
|
|
407
|
+
if (!resolved) {
|
|
408
|
+
const { HttpException } = await import('../http/exceptions.ts');
|
|
409
|
+
throw new HttpException('Forbidden resource', 403);
|
|
270
410
|
}
|
|
271
|
-
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let processedArgs = args;
|
|
414
|
+
for (const pipe of compiledPipes) {
|
|
415
|
+
const transformed = pipe.instance.transform(processedArgs, {
|
|
416
|
+
type: 'custom',
|
|
417
|
+
metatype: undefined,
|
|
418
|
+
data: undefined,
|
|
419
|
+
});
|
|
420
|
+
processedArgs = transformed instanceof Promise ? await transformed : transformed;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const executeHandler = async () => {
|
|
424
|
+
const argsMetadata: { parameterIndex: number; name: string }[] =
|
|
425
|
+
Reflect.getMetadata('calyx:args', resolverInstance, fieldRes.propertyKey) || [];
|
|
426
|
+
const params: any[] = [];
|
|
427
|
+
const paramTypes = Reflect.getMetadata('design:paramtypes', resolverInstance, fieldRes.propertyKey) || [];
|
|
428
|
+
for (const arg of argsMetadata) {
|
|
429
|
+
const paramType = paramTypes[arg.parameterIndex] || String;
|
|
430
|
+
if (!arg.name && typeof paramType === 'function' && Reflect.hasMetadata('calyx:args_type', paramType)) {
|
|
431
|
+
const argInst = new paramType();
|
|
432
|
+
const fieldsMetadata = Reflect.getMetadata('calyx:fields', paramType) || [];
|
|
433
|
+
for (const f of fieldsMetadata) {
|
|
434
|
+
argInst[f.propertyKey] = processedArgs[f.propertyKey];
|
|
435
|
+
}
|
|
436
|
+
params[arg.parameterIndex] = argInst;
|
|
437
|
+
} else {
|
|
438
|
+
const argName = arg.name || 'args';
|
|
439
|
+
params[arg.parameterIndex] = processedArgs[argName];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, fieldRes.propertyKey) || [];
|
|
443
|
+
for (const idx of parentParams) {
|
|
444
|
+
params[idx] = parent;
|
|
445
|
+
}
|
|
446
|
+
return resolverInstance[fieldRes.propertyKey](...params);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
if (compiledInterceptors.length > 0) {
|
|
450
|
+
let interceptorIndex = 0;
|
|
451
|
+
const nextHandler = async (): Promise<any> => {
|
|
452
|
+
if (interceptorIndex < compiledInterceptors.length) {
|
|
453
|
+
const interceptor = compiledInterceptors[interceptorIndex++];
|
|
454
|
+
return interceptor.instance.intercept(execContext, { handle: () => nextHandler() });
|
|
455
|
+
}
|
|
456
|
+
return executeHandler();
|
|
457
|
+
};
|
|
458
|
+
return await nextHandler();
|
|
272
459
|
} else {
|
|
273
|
-
|
|
274
|
-
params[arg.parameterIndex] = args[argName];
|
|
460
|
+
return await executeHandler();
|
|
275
461
|
}
|
|
462
|
+
} catch (err: any) {
|
|
463
|
+
if (compiledFilters.length > 0) {
|
|
464
|
+
for (const filter of compiledFilters) {
|
|
465
|
+
const catchException = Reflect.getMetadata('calyx:catch', filter.token) || [];
|
|
466
|
+
if (catchException.length === 0 || catchException.some((exc: any) => err instanceof exc)) {
|
|
467
|
+
const filterResult = filter.instance.catch(err, execContext);
|
|
468
|
+
if (filterResult !== undefined) {
|
|
469
|
+
return filterResult;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
throw err;
|
|
276
475
|
}
|
|
277
|
-
const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, fieldRes.propertyKey) || [];
|
|
278
|
-
for (const idx of parentParams) {
|
|
279
|
-
params[idx] = parent;
|
|
280
|
-
}
|
|
281
|
-
return resolverInstance[fieldRes.propertyKey](...params);
|
|
282
476
|
};
|
|
283
477
|
}
|
|
284
478
|
}
|
package/src/http/application.ts
CHANGED
|
@@ -1219,6 +1219,7 @@ export class CalyxApplication {
|
|
|
1219
1219
|
const result = await graphql({
|
|
1220
1220
|
schema: this.graphqlSchema,
|
|
1221
1221
|
source: query,
|
|
1222
|
+
contextValue: { req },
|
|
1222
1223
|
variableValues: variables,
|
|
1223
1224
|
});
|
|
1224
1225
|
|
|
@@ -1242,10 +1243,20 @@ export class CalyxApplication {
|
|
|
1242
1243
|
this.buildRoutes();
|
|
1243
1244
|
await this.init();
|
|
1244
1245
|
|
|
1246
|
+
if (this.graphqlSchema) {
|
|
1247
|
+
this.hasWebSockets = true;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1245
1250
|
const fetchHandler = (req: Request, server: any) => {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
if (
|
|
1251
|
+
const url = new URL(req.url);
|
|
1252
|
+
if (req.headers.get('upgrade') === 'websocket') {
|
|
1253
|
+
if (url.pathname === '/graphql' && this.graphqlSchema) {
|
|
1254
|
+
const success = server.upgrade(req, { data: { isGraphQL: true, req } });
|
|
1255
|
+
if (success) return undefined;
|
|
1256
|
+
} else if (this.hasWebSockets) {
|
|
1257
|
+
const success = server.upgrade(req);
|
|
1258
|
+
if (success) return undefined;
|
|
1259
|
+
}
|
|
1249
1260
|
}
|
|
1250
1261
|
return this.handleRequest(req);
|
|
1251
1262
|
};
|
|
@@ -1258,16 +1269,34 @@ export class CalyxApplication {
|
|
|
1258
1269
|
if (this.hasWebSockets) {
|
|
1259
1270
|
serveOptions.websocket = {
|
|
1260
1271
|
open: (ws: any) => {
|
|
1272
|
+
if (ws.data?.isGraphQL) {
|
|
1273
|
+
ws.data.subscriptions = new Map();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1261
1276
|
for (const gateway of this.sharedWebSockets) {
|
|
1262
1277
|
this.dispatchWsConnection(gateway, ws);
|
|
1263
1278
|
}
|
|
1264
1279
|
},
|
|
1265
|
-
message: (ws: any, message: any) => {
|
|
1280
|
+
message: async (ws: any, message: any) => {
|
|
1281
|
+
if (ws.data?.isGraphQL) {
|
|
1282
|
+
await this.handleGraphQLWsMessage(ws, message);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1266
1285
|
for (const gateway of this.sharedWebSockets) {
|
|
1267
1286
|
this.dispatchWsMessage(gateway, ws, message);
|
|
1268
1287
|
}
|
|
1269
1288
|
},
|
|
1270
1289
|
close: (ws: any) => {
|
|
1290
|
+
if (ws.data?.isGraphQL) {
|
|
1291
|
+
if (ws.data.subscriptions) {
|
|
1292
|
+
for (const sub of ws.data.subscriptions.values()) {
|
|
1293
|
+
if (typeof sub.return === 'function') {
|
|
1294
|
+
sub.return();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1271
1300
|
for (const gateway of this.sharedWebSockets) {
|
|
1272
1301
|
this.dispatchWsDisconnect(gateway, ws);
|
|
1273
1302
|
}
|
|
@@ -1279,6 +1308,77 @@ export class CalyxApplication {
|
|
|
1279
1308
|
return this.server;
|
|
1280
1309
|
}
|
|
1281
1310
|
|
|
1311
|
+
private async handleGraphQLWsMessage(ws: any, message: any) {
|
|
1312
|
+
try {
|
|
1313
|
+
const data = JSON.parse(typeof message === 'string' ? message : message.toString());
|
|
1314
|
+
|
|
1315
|
+
switch (data.type) {
|
|
1316
|
+
case 'connection_init': {
|
|
1317
|
+
ws.send(JSON.stringify({ type: 'connection_ack' }));
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
case 'subscribe': {
|
|
1321
|
+
const { id, payload } = data;
|
|
1322
|
+
const { query, variables } = payload;
|
|
1323
|
+
|
|
1324
|
+
const { subscribe, parse } = await import('graphql');
|
|
1325
|
+
|
|
1326
|
+
const subResult = await subscribe({
|
|
1327
|
+
schema: this.graphqlSchema,
|
|
1328
|
+
document: parse(query),
|
|
1329
|
+
variableValues: variables,
|
|
1330
|
+
contextValue: { req: ws.data?.req },
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
if (subResult && Symbol.asyncIterator in subResult) {
|
|
1334
|
+
ws.data.subscriptions.set(id, subResult);
|
|
1335
|
+
(async () => {
|
|
1336
|
+
try {
|
|
1337
|
+
for await (const val of subResult) {
|
|
1338
|
+
ws.send(JSON.stringify({
|
|
1339
|
+
type: 'next',
|
|
1340
|
+
id,
|
|
1341
|
+
payload: val,
|
|
1342
|
+
}));
|
|
1343
|
+
}
|
|
1344
|
+
ws.send(JSON.stringify({
|
|
1345
|
+
type: 'complete',
|
|
1346
|
+
id,
|
|
1347
|
+
}));
|
|
1348
|
+
} catch (err: any) {
|
|
1349
|
+
ws.send(JSON.stringify({
|
|
1350
|
+
type: 'error',
|
|
1351
|
+
id,
|
|
1352
|
+
payload: [{ message: err.message }],
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
1355
|
+
})();
|
|
1356
|
+
} else {
|
|
1357
|
+
ws.send(JSON.stringify({
|
|
1358
|
+
type: 'error',
|
|
1359
|
+
id,
|
|
1360
|
+
payload: subResult.errors || [{ message: 'GraphQL validation failed' }],
|
|
1361
|
+
}));
|
|
1362
|
+
}
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
case 'complete': {
|
|
1366
|
+
const { id } = data;
|
|
1367
|
+
const sub = ws.data.subscriptions.get(id);
|
|
1368
|
+
if (sub) {
|
|
1369
|
+
if (typeof sub.return === 'function') {
|
|
1370
|
+
sub.return();
|
|
1371
|
+
}
|
|
1372
|
+
ws.data.subscriptions.delete(id);
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
} catch (err: any) {
|
|
1378
|
+
console.error('GraphQL WS error:', err);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1282
1382
|
private cleanupListeners: (() => void)[] = [];
|
|
1283
1383
|
|
|
1284
1384
|
enableShutdownHooks(signals: string[] = ['SIGTERM', 'SIGINT']) {
|
package/tests/graphql.test.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
CalyxFactory,
|
|
5
|
+
UseGuards,
|
|
6
|
+
UseInterceptors,
|
|
7
|
+
CanActivate,
|
|
8
|
+
NestInterceptor,
|
|
9
|
+
CallHandler,
|
|
10
|
+
ExecutionContext,
|
|
11
|
+
} from '../src/index.ts';
|
|
3
12
|
import {
|
|
4
13
|
Resolver,
|
|
5
14
|
Query,
|
|
6
15
|
Mutation,
|
|
16
|
+
Subscription,
|
|
7
17
|
ResolveField,
|
|
8
18
|
Args,
|
|
9
19
|
Parent,
|
|
@@ -14,7 +24,7 @@ import {
|
|
|
14
24
|
GraphQLModule,
|
|
15
25
|
} from '../src/graphql/index.ts';
|
|
16
26
|
|
|
17
|
-
// 1.
|
|
27
|
+
// 1. DTOs
|
|
18
28
|
@ObjectType()
|
|
19
29
|
class Author {
|
|
20
30
|
@Field()
|
|
@@ -36,7 +46,6 @@ class PostGql {
|
|
|
36
46
|
author!: Author;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
// 2. GraphQL Input Type DTO
|
|
40
49
|
@InputType()
|
|
41
50
|
class CreatePostInput {
|
|
42
51
|
@Field()
|
|
@@ -46,14 +55,33 @@ class CreatePostInput {
|
|
|
46
55
|
authorId!: number;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
// 3. GraphQL Args Type DTO (Flattened Args)
|
|
50
58
|
@ArgsType()
|
|
51
59
|
class GetPostArgs {
|
|
52
60
|
@Field()
|
|
53
61
|
id!: number;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
//
|
|
64
|
+
// 2. Enhancers (Guard & Interceptor)
|
|
65
|
+
class GqlGuard implements CanActivate {
|
|
66
|
+
canActivate(context: ExecutionContext): boolean {
|
|
67
|
+
const gqlContext = context.getArgByIndex(2);
|
|
68
|
+
const req = gqlContext?.req;
|
|
69
|
+
const auth = req?.headers?.get('Authorization');
|
|
70
|
+
return auth === 'allow';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class GqlInterceptor implements NestInterceptor {
|
|
75
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
|
|
76
|
+
const res = await next.handle();
|
|
77
|
+
if (res && typeof res === 'object') {
|
|
78
|
+
return { ...res, title: res.title + ' [intercepted]' };
|
|
79
|
+
}
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Resolver Class
|
|
57
85
|
@Resolver(PostGql)
|
|
58
86
|
class PostResolver {
|
|
59
87
|
@Query(() => PostGql)
|
|
@@ -61,7 +89,18 @@ class PostResolver {
|
|
|
61
89
|
return {
|
|
62
90
|
id: args.id,
|
|
63
91
|
title: `Calyx: GraphQL JIT Performance`,
|
|
64
|
-
authorId: 456,
|
|
92
|
+
authorId: 456,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Query(() => PostGql)
|
|
97
|
+
@UseGuards(GqlGuard)
|
|
98
|
+
@UseInterceptors(GqlInterceptor)
|
|
99
|
+
getPostSecured(@Args() args: GetPostArgs) {
|
|
100
|
+
return {
|
|
101
|
+
id: args.id,
|
|
102
|
+
title: `Calyx Secure`,
|
|
103
|
+
authorId: 111,
|
|
65
104
|
};
|
|
66
105
|
}
|
|
67
106
|
|
|
@@ -74,6 +113,32 @@ class PostResolver {
|
|
|
74
113
|
};
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
@Subscription(() => PostGql)
|
|
117
|
+
postAdded() {
|
|
118
|
+
return {
|
|
119
|
+
[Symbol.asyncIterator]() {
|
|
120
|
+
let index = 0;
|
|
121
|
+
return {
|
|
122
|
+
async next() {
|
|
123
|
+
if (index < 1) {
|
|
124
|
+
index++;
|
|
125
|
+
return {
|
|
126
|
+
value: {
|
|
127
|
+
postAdded: {
|
|
128
|
+
id: 777,
|
|
129
|
+
title: 'New Post Added',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
done: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { value: undefined, done: true };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
77
142
|
@ResolveField(() => Author)
|
|
78
143
|
author(@Parent() post: any) {
|
|
79
144
|
return {
|
|
@@ -173,4 +238,114 @@ describe('Native Code-First GraphQL Module', () => {
|
|
|
173
238
|
},
|
|
174
239
|
});
|
|
175
240
|
});
|
|
241
|
+
|
|
242
|
+
test('should run Guards and deny access if guard denies', async () => {
|
|
243
|
+
const res = await fetch(`${baseUrl}/graphql`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'content-type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
query: `
|
|
248
|
+
query {
|
|
249
|
+
getPostSecured(id: 456) {
|
|
250
|
+
id
|
|
251
|
+
title
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
`,
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(res.status).toBe(200);
|
|
259
|
+
const body = await res.json();
|
|
260
|
+
expect(body.errors).toBeDefined();
|
|
261
|
+
expect(body.errors[0].message).toContain('Forbidden resource');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('should run Guards and Interceptors successfully if guard approves', async () => {
|
|
265
|
+
const res = await fetch(`${baseUrl}/graphql`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: {
|
|
268
|
+
'content-type': 'application/json',
|
|
269
|
+
'Authorization': 'allow',
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
query: `
|
|
273
|
+
query {
|
|
274
|
+
getPostSecured(id: 456) {
|
|
275
|
+
id
|
|
276
|
+
title
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(res.status).toBe(200);
|
|
284
|
+
const body = await res.json();
|
|
285
|
+
expect(body.errors).toBeUndefined();
|
|
286
|
+
expect(body.data.getPostSecured).toEqual({
|
|
287
|
+
id: 456,
|
|
288
|
+
title: 'Calyx Secure [intercepted]',
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('should execute subscription over WebSocket using graphql-ws protocol', async () => {
|
|
293
|
+
const ws = new WebSocket(`ws://localhost:${PORT}/graphql`);
|
|
294
|
+
const messages: any[] = [];
|
|
295
|
+
ws.onmessage = (event) => {
|
|
296
|
+
messages.push(JSON.parse(event.data));
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await new Promise((resolve) => {
|
|
300
|
+
ws.onopen = resolve;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Send connection_init
|
|
304
|
+
ws.send(JSON.stringify({ type: 'connection_init' }));
|
|
305
|
+
|
|
306
|
+
// Wait for connection_ack
|
|
307
|
+
await new Promise((resolve) => {
|
|
308
|
+
const check = setInterval(() => {
|
|
309
|
+
if (messages.some((m) => m.type === 'connection_ack')) {
|
|
310
|
+
clearInterval(check);
|
|
311
|
+
resolve(null);
|
|
312
|
+
}
|
|
313
|
+
}, 5);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Send subscribe
|
|
317
|
+
ws.send(JSON.stringify({
|
|
318
|
+
type: 'subscribe',
|
|
319
|
+
id: '1',
|
|
320
|
+
payload: {
|
|
321
|
+
query: `
|
|
322
|
+
subscription {
|
|
323
|
+
postAdded {
|
|
324
|
+
id
|
|
325
|
+
title
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
`,
|
|
329
|
+
},
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
// Wait for next message
|
|
333
|
+
await new Promise((resolve) => {
|
|
334
|
+
const check = setInterval(() => {
|
|
335
|
+
if (messages.some((m) => m.type === 'next' && m.id === '1')) {
|
|
336
|
+
clearInterval(check);
|
|
337
|
+
resolve(null);
|
|
338
|
+
}
|
|
339
|
+
}, 5);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const nextMsg = messages.find((m) => m.type === 'next' && m.id === '1');
|
|
343
|
+
expect(nextMsg).toBeDefined();
|
|
344
|
+
expect(nextMsg.payload.data.postAdded).toEqual({
|
|
345
|
+
id: 777,
|
|
346
|
+
title: 'New Post Added',
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
ws.close();
|
|
350
|
+
});
|
|
176
351
|
});
|