@martel/calyx 1.9.0 → 1.10.1

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,17 @@
1
+ ## [1.10.1](https://github.com/bmartel/calyx/compare/v1.10.0...v1.10.1) (2026-07-01)
2
+
3
+
4
+ ### Performance Improvements
5
+
6
+ * **graphql,openapi:** implement query AST caching and swagger response caching ([8310045](https://github.com/bmartel/calyx/commit/8310045265512553214a0a4040bc02d5085c77fa))
7
+
8
+ # [1.10.0](https://github.com/bmartel/calyx/compare/v1.9.0...v1.10.0) (2026-07-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * **graphql:** integrate context-aware enhancers pipeline and native ws subscriptions ([24e86f2](https://github.com/bmartel/calyx/commit/24e86f2c180a1b3b179e2acb9a198f514b204f82))
14
+
1
15
  # [1.9.0](https://github.com/bmartel/calyx/compare/v1.8.0...v1.9.0) (2026-07-01)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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
  }
@@ -119,6 +119,7 @@ export class CalyxApplication {
119
119
  private hasWebSockets = false;
120
120
  private serverPort = 3000;
121
121
  private graphqlSchema: any = null;
122
+ private graphqlQueryCache = new Map<string, any>();
122
123
  private isInitialized = false;
123
124
  private versioningOptions?: VersioningOptions;
124
125
 
@@ -1215,10 +1216,25 @@ export class CalyxApplication {
1215
1216
  const body = await req.json() as any;
1216
1217
  const { query, variables } = body;
1217
1218
 
1218
- const { graphql } = await import('graphql');
1219
- const result = await graphql({
1219
+ const { parse, validate, execute } = await import('graphql');
1220
+
1221
+ let document = this.graphqlQueryCache.get(query);
1222
+ if (!document) {
1223
+ document = parse(query);
1224
+ const errors = validate(this.graphqlSchema, document);
1225
+ if (errors.length > 0) {
1226
+ return new Response(JSON.stringify({ errors }), {
1227
+ status: 200,
1228
+ headers: { 'content-type': 'application/json' },
1229
+ });
1230
+ }
1231
+ this.graphqlQueryCache.set(query, document);
1232
+ }
1233
+
1234
+ const result = await execute({
1220
1235
  schema: this.graphqlSchema,
1221
- source: query,
1236
+ document,
1237
+ contextValue: { req },
1222
1238
  variableValues: variables,
1223
1239
  });
1224
1240
 
@@ -1242,10 +1258,20 @@ export class CalyxApplication {
1242
1258
  this.buildRoutes();
1243
1259
  await this.init();
1244
1260
 
1261
+ if (this.graphqlSchema) {
1262
+ this.hasWebSockets = true;
1263
+ }
1264
+
1245
1265
  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;
1266
+ const url = new URL(req.url);
1267
+ if (req.headers.get('upgrade') === 'websocket') {
1268
+ if (url.pathname === '/graphql' && this.graphqlSchema) {
1269
+ const success = server.upgrade(req, { data: { isGraphQL: true, req } });
1270
+ if (success) return undefined;
1271
+ } else if (this.hasWebSockets) {
1272
+ const success = server.upgrade(req);
1273
+ if (success) return undefined;
1274
+ }
1249
1275
  }
1250
1276
  return this.handleRequest(req);
1251
1277
  };
@@ -1258,16 +1284,34 @@ export class CalyxApplication {
1258
1284
  if (this.hasWebSockets) {
1259
1285
  serveOptions.websocket = {
1260
1286
  open: (ws: any) => {
1287
+ if (ws.data?.isGraphQL) {
1288
+ ws.data.subscriptions = new Map();
1289
+ return;
1290
+ }
1261
1291
  for (const gateway of this.sharedWebSockets) {
1262
1292
  this.dispatchWsConnection(gateway, ws);
1263
1293
  }
1264
1294
  },
1265
- message: (ws: any, message: any) => {
1295
+ message: async (ws: any, message: any) => {
1296
+ if (ws.data?.isGraphQL) {
1297
+ await this.handleGraphQLWsMessage(ws, message);
1298
+ return;
1299
+ }
1266
1300
  for (const gateway of this.sharedWebSockets) {
1267
1301
  this.dispatchWsMessage(gateway, ws, message);
1268
1302
  }
1269
1303
  },
1270
1304
  close: (ws: any) => {
1305
+ if (ws.data?.isGraphQL) {
1306
+ if (ws.data.subscriptions) {
1307
+ for (const sub of ws.data.subscriptions.values()) {
1308
+ if (typeof sub.return === 'function') {
1309
+ sub.return();
1310
+ }
1311
+ }
1312
+ }
1313
+ return;
1314
+ }
1271
1315
  for (const gateway of this.sharedWebSockets) {
1272
1316
  this.dispatchWsDisconnect(gateway, ws);
1273
1317
  }
@@ -1279,6 +1323,88 @@ export class CalyxApplication {
1279
1323
  return this.server;
1280
1324
  }
1281
1325
 
1326
+ private async handleGraphQLWsMessage(ws: any, message: any) {
1327
+ try {
1328
+ const data = JSON.parse(typeof message === 'string' ? message : message.toString());
1329
+
1330
+ switch (data.type) {
1331
+ case 'connection_init': {
1332
+ ws.send(JSON.stringify({ type: 'connection_ack' }));
1333
+ break;
1334
+ }
1335
+ case 'subscribe': {
1336
+ const { id, payload } = data;
1337
+ const { query, variables } = payload;
1338
+
1339
+ const { subscribe, parse, validate } = await import('graphql');
1340
+
1341
+ let document = this.graphqlQueryCache.get(query);
1342
+ if (!document) {
1343
+ document = parse(query);
1344
+ const errors = validate(this.graphqlSchema, document);
1345
+ if (errors.length > 0) {
1346
+ ws.send(JSON.stringify({ type: 'error', id, payload: errors }));
1347
+ break;
1348
+ }
1349
+ this.graphqlQueryCache.set(query, document);
1350
+ }
1351
+
1352
+ const subResult = await subscribe({
1353
+ schema: this.graphqlSchema,
1354
+ document,
1355
+ variableValues: variables,
1356
+ contextValue: { req: ws.data?.req },
1357
+ });
1358
+
1359
+ if (subResult && Symbol.asyncIterator in subResult) {
1360
+ ws.data.subscriptions.set(id, subResult);
1361
+ (async () => {
1362
+ try {
1363
+ for await (const val of subResult) {
1364
+ ws.send(JSON.stringify({
1365
+ type: 'next',
1366
+ id,
1367
+ payload: val,
1368
+ }));
1369
+ }
1370
+ ws.send(JSON.stringify({
1371
+ type: 'complete',
1372
+ id,
1373
+ }));
1374
+ } catch (err: any) {
1375
+ ws.send(JSON.stringify({
1376
+ type: 'error',
1377
+ id,
1378
+ payload: [{ message: err.message }],
1379
+ }));
1380
+ }
1381
+ })();
1382
+ } else {
1383
+ ws.send(JSON.stringify({
1384
+ type: 'error',
1385
+ id,
1386
+ payload: subResult.errors || [{ message: 'GraphQL validation failed' }],
1387
+ }));
1388
+ }
1389
+ break;
1390
+ }
1391
+ case 'complete': {
1392
+ const { id } = data;
1393
+ const sub = ws.data.subscriptions.get(id);
1394
+ if (sub) {
1395
+ if (typeof sub.return === 'function') {
1396
+ sub.return();
1397
+ }
1398
+ ws.data.subscriptions.delete(id);
1399
+ }
1400
+ break;
1401
+ }
1402
+ }
1403
+ } catch (err: any) {
1404
+ console.error('GraphQL WS error:', err);
1405
+ }
1406
+ }
1407
+
1282
1408
  private cleanupListeners: (() => void)[] = [];
1283
1409
 
1284
1410
  enableShutdownHooks(signals: string[] = ['SIGTERM', 'SIGINT']) {
@@ -271,12 +271,14 @@ export class SwaggerModule {
271
271
  const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
272
272
  const uiPath = `/${path}`.replace(/\/\/+/g, '/');
273
273
 
274
+ const jsonString = JSON.stringify(document);
275
+
274
276
  app.use((req: any, res: any, next: any) => {
275
277
  const url = new URL(req.url);
276
278
  if (url.pathname === jsonPath && req.method === 'GET') {
277
279
  res.status(200);
278
280
  res.set('content-type', 'application/json');
279
- res.send(JSON.stringify(document));
281
+ res.send(jsonString);
280
282
  return;
281
283
  }
282
284
  next();
@@ -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
  });