@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -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 params: any[] = [];
184
- for (const arg of argsMetadata) {
185
- const paramType = paramTypes[arg.parameterIndex] || String;
186
- if (!arg.name && typeof paramType === 'function' && Reflect.hasMetadata('calyx:args_type', paramType)) {
187
- const argInst = new paramType();
188
- const fieldsMetadata = Reflect.getMetadata('calyx:fields', paramType) || [];
189
- for (const f of fieldsMetadata) {
190
- argInst[f.propertyKey] = args[f.propertyKey];
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
- params[arg.parameterIndex] = argInst;
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
- const argName = arg.name || 'args';
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
- targetFields[fieldName].resolve = async (parent: any, args: any, context: any) => {
259
- const argsMetadata: { parameterIndex: number; name: string }[] =
260
- Reflect.getMetadata('calyx:args', resolverInstance, fieldRes.propertyKey) || [];
261
- const params: any[] = [];
262
- const paramTypes = Reflect.getMetadata('design:paramtypes', resolverInstance, fieldRes.propertyKey) || [];
263
- for (const arg of argsMetadata) {
264
- const paramType = paramTypes[arg.parameterIndex] || String;
265
- if (!arg.name && typeof paramType === 'function' && Reflect.hasMetadata('calyx:args_type', paramType)) {
266
- const argInst = new paramType();
267
- const fieldsMetadata = Reflect.getMetadata('calyx:fields', paramType) || [];
268
- for (const f of fieldsMetadata) {
269
- argInst[f.propertyKey] = args[f.propertyKey];
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
- params[arg.parameterIndex] = argInst;
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
- const argName = arg.name || 'args';
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
  }
@@ -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
- if (this.hasWebSockets && req.headers.get('upgrade') === 'websocket') {
1247
- const success = server.upgrade(req);
1248
- if (success) return undefined;
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']) {
@@ -1,9 +1,19 @@
1
1
  import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
- import { Module, CalyxFactory } from '../src/index.ts';
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. GraphQL Object Type DTOs
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
- // 4. Resolver Class
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, // to be resolved by ResolveField
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
  });