@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 +14 -0
- package/package.json +1 -1
- package/src/graphql/graphql.module.ts +232 -38
- package/src/http/application.ts +133 -7
- package/src/openapi/swagger.module.ts +3 -1
- package/tests/graphql.test.ts +181 -6
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
|
@@ -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
|
@@ -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 {
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
if (
|
|
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(
|
|
281
|
+
res.send(jsonString);
|
|
280
282
|
return;
|
|
281
283
|
}
|
|
282
284
|
next();
|
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
|
});
|