@martel/calyx 1.7.0 → 1.8.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.8.0](https://github.com/bmartel/calyx/compare/v1.7.0...v1.8.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **graphql,openapi:** implement native code-first GraphQLModule and SwaggerModule spec/UI serving ([705c0c0](https://github.com/bmartel/calyx/commit/705c0c0a821cb9e7133d2ad1f1b2103f3aa57572))
7
+
1
8
  # [1.7.0](https://github.com/bmartel/calyx/compare/v1.6.0...v1.7.0) (2026-07-01)
2
9
 
3
10
 
package/bun.lock CHANGED
@@ -5,6 +5,7 @@
5
5
  "": {
6
6
  "name": "@martel/calyx",
7
7
  "dependencies": {
8
+ "graphql": "^17.0.1",
8
9
  "reflect-metadata": "^0.2.2",
9
10
  },
10
11
  "devDependencies": {
@@ -322,6 +323,8 @@
322
323
 
323
324
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
324
325
 
326
+ "graphql": ["graphql@17.0.1", "", {}, "sha512-8eWbg5Zcv/8o20nzEjHUGPTj20MLFJjc5kagbIPxbaeGxvFwpitJhemEC/k17n5+UD4M/9ea5rTuce78mELujQ=="],
327
+
325
328
  "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="],
326
329
 
327
330
  "has-async-hooks": ["has-async-hooks@1.0.0", "", {}, "sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "benchmark": "bun run benchmarks/index.ts"
13
13
  },
14
14
  "dependencies": {
15
+ "graphql": "^17.0.1",
15
16
  "reflect-metadata": "^0.2.2"
16
17
  },
17
18
  "devDependencies": {
@@ -0,0 +1,62 @@
1
+ import { METADATA_KEYS } from '../core/metadata.ts';
2
+
3
+ export function Resolver(nameOrClass?: any): ClassDecorator {
4
+ return (target) => {
5
+ Reflect.defineMetadata('calyx:resolver', nameOrClass || target, target);
6
+ Reflect.defineMetadata(METADATA_KEYS.INJECTABLE, true, target);
7
+ };
8
+ }
9
+
10
+ export function Query(typeFunc?: (returns: any) => any, options?: { name?: string }): MethodDecorator {
11
+ return (target, propertyKey) => {
12
+ const queries = Reflect.getOwnMetadata('calyx:queries', target.constructor) || [];
13
+ queries.push({ propertyKey, typeFunc, options });
14
+ Reflect.defineMetadata('calyx:queries', queries, target.constructor);
15
+ };
16
+ }
17
+
18
+ export function Mutation(typeFunc?: (returns: any) => any, options?: { name?: string }): MethodDecorator {
19
+ return (target, propertyKey) => {
20
+ const mutations = Reflect.getOwnMetadata('calyx:mutations', target.constructor) || [];
21
+ mutations.push({ propertyKey, typeFunc, options });
22
+ Reflect.defineMetadata('calyx:mutations', mutations, target.constructor);
23
+ };
24
+ }
25
+
26
+ export function ResolveField(typeFunc?: (returns: any) => any, options?: { name?: string }): MethodDecorator {
27
+ return (target, propertyKey) => {
28
+ const fields = Reflect.getOwnMetadata('calyx:resolve_fields', target.constructor) || [];
29
+ fields.push({ propertyKey, typeFunc, options });
30
+ Reflect.defineMetadata('calyx:resolve_fields', fields, target.constructor);
31
+ };
32
+ }
33
+
34
+ export function Args(name?: string): ParameterDecorator {
35
+ return (target, propertyKey, parameterIndex) => {
36
+ const args = Reflect.getOwnMetadata('calyx:args', target, propertyKey!) || [];
37
+ args.push({ parameterIndex, name });
38
+ Reflect.defineMetadata('calyx:args', args, target, propertyKey!);
39
+ };
40
+ }
41
+
42
+ export function Parent(): ParameterDecorator {
43
+ return (target, propertyKey, parameterIndex) => {
44
+ const parentParams = Reflect.getOwnMetadata('calyx:parent', target, propertyKey!) || [];
45
+ parentParams.push(parameterIndex);
46
+ Reflect.defineMetadata('calyx:parent', parentParams, target, propertyKey!);
47
+ };
48
+ }
49
+
50
+ export function ObjectType(): ClassDecorator {
51
+ return (target) => {
52
+ Reflect.defineMetadata('calyx:object_type', true, target);
53
+ };
54
+ }
55
+
56
+ export function Field(typeFunc?: (returns: any) => any, options?: { nullable?: boolean }): PropertyDecorator {
57
+ return (target, propertyKey) => {
58
+ const fields = Reflect.getOwnMetadata('calyx:fields', target.constructor) || [];
59
+ fields.push({ propertyKey: String(propertyKey), typeFunc, options });
60
+ Reflect.defineMetadata('calyx:fields', fields, target.constructor);
61
+ };
62
+ }
@@ -0,0 +1,166 @@
1
+ import { CalyxContainer } from '../core/container.ts';
2
+ import { Module } from '../core/decorators.ts';
3
+ import {
4
+ GraphQLSchema,
5
+ GraphQLObjectType,
6
+ GraphQLString,
7
+ GraphQLInt,
8
+ GraphQLFloat,
9
+ GraphQLBoolean,
10
+ GraphQLList,
11
+ GraphQLNonNull,
12
+ } from 'graphql';
13
+
14
+ @Module({})
15
+ export class GraphQLModule {
16
+ static buildSchema(container: CalyxContainer): GraphQLSchema | null {
17
+ const instances = container.getProviderAndControllerInstances();
18
+ const resolverInstances = instances.filter(
19
+ (inst) => inst && inst.constructor && Reflect.hasMetadata('calyx:resolver', inst.constructor)
20
+ );
21
+
22
+ if (resolverInstances.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ const typeMap = new Map<any, any>();
27
+
28
+ function getGraphQLType(typeClass: any): any {
29
+ if (typeClass === String) return GraphQLString;
30
+ if (typeClass === Number) return GraphQLFloat;
31
+ if (typeClass === Boolean) return GraphQLBoolean;
32
+ if (typeMap.has(typeClass)) return typeMap.get(typeClass);
33
+
34
+ if (typeof typeClass === 'function' && Reflect.hasMetadata('calyx:object_type', typeClass)) {
35
+ const gqlObjectType = new GraphQLObjectType({
36
+ name: typeClass.name,
37
+ fields: () => {
38
+ const fieldsMetadata: { propertyKey: string; typeFunc?: any; options?: any }[] =
39
+ Reflect.getMetadata('calyx:fields', typeClass) || [];
40
+
41
+ const fieldsConfig: any = {};
42
+ for (const field of fieldsMetadata) {
43
+ let returnTypeClass = field.typeFunc ? field.typeFunc(null) : undefined;
44
+ if (!returnTypeClass) {
45
+ returnTypeClass = Reflect.getMetadata('design:type', typeClass.prototype, field.propertyKey);
46
+ }
47
+ if (!returnTypeClass) {
48
+ returnTypeClass = String;
49
+ }
50
+
51
+ let gqlType = getGraphQLType(returnTypeClass);
52
+ if (Array.isArray(returnTypeClass)) {
53
+ gqlType = new GraphQLList(getGraphQLType(returnTypeClass[0]));
54
+ }
55
+ if (!field.options?.nullable) {
56
+ gqlType = new GraphQLNonNull(gqlType);
57
+ }
58
+ fieldsConfig[field.propertyKey] = { type: gqlType };
59
+ }
60
+ return fieldsConfig;
61
+ },
62
+ });
63
+
64
+ typeMap.set(typeClass, gqlObjectType);
65
+ return gqlObjectType;
66
+ }
67
+
68
+ return GraphQLString;
69
+ }
70
+
71
+ const queryFields: any = {};
72
+ const mutationFields: any = {};
73
+
74
+ for (const resolverInstance of resolverInstances) {
75
+ const resolverClass = resolverInstance.constructor;
76
+
77
+ // Queries
78
+ const queries: { propertyKey: string | symbol; typeFunc?: any; options?: any }[] =
79
+ Reflect.getMetadata('calyx:queries', resolverClass) || [];
80
+ for (const query of queries) {
81
+ const returnTypeClass = query.typeFunc ? query.typeFunc(null) : String;
82
+ let gqlType = getGraphQLType(returnTypeClass);
83
+ if (Array.isArray(returnTypeClass)) {
84
+ gqlType = new GraphQLList(getGraphQLType(returnTypeClass[0]));
85
+ }
86
+
87
+ const argsMetadata: { parameterIndex: number; name: string }[] =
88
+ Reflect.getMetadata('calyx:args', resolverInstance, query.propertyKey) || [];
89
+ const argsConfig: any = {};
90
+
91
+ const paramTypes = Reflect.getMetadata('design:paramtypes', resolverInstance, query.propertyKey) || [];
92
+ for (const arg of argsMetadata) {
93
+ const paramType = paramTypes[arg.parameterIndex] || String;
94
+ argsConfig[arg.name] = { type: getGraphQLType(paramType) };
95
+ }
96
+
97
+ const queryName = query.options?.name || String(query.propertyKey);
98
+
99
+ queryFields[queryName] = {
100
+ type: gqlType,
101
+ args: argsConfig,
102
+ resolve: async (parent: any, args: any, context: any) => {
103
+ const params: any[] = [];
104
+ for (const arg of argsMetadata) {
105
+ params[arg.parameterIndex] = args[arg.name];
106
+ }
107
+ const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, query.propertyKey) || [];
108
+ for (const idx of parentParams) {
109
+ params[idx] = parent;
110
+ }
111
+ return resolverInstance[query.propertyKey](...params);
112
+ },
113
+ };
114
+ }
115
+
116
+ // Resolve fields mapping
117
+ const resolvedType = Reflect.getMetadata('calyx:resolver', resolverClass);
118
+ const fieldResolvers: { propertyKey: string | symbol; typeFunc?: any; options?: any }[] =
119
+ Reflect.getMetadata('calyx:resolve_fields', resolverClass) || [];
120
+
121
+ if (resolvedType && fieldResolvers.length > 0) {
122
+ const targetGqlType = getGraphQLType(resolvedType);
123
+ if (targetGqlType && typeof targetGqlType.getFields === 'function') {
124
+ const targetFields = targetGqlType.getFields();
125
+ for (const fieldRes of fieldResolvers) {
126
+ const fieldName = fieldRes.options?.name || String(fieldRes.propertyKey);
127
+ if (targetFields[fieldName]) {
128
+ targetFields[fieldName].resolve = async (parent: any, args: any, context: any) => {
129
+ const argsMetadata: { parameterIndex: number; name: string }[] =
130
+ Reflect.getMetadata('calyx:args', resolverInstance, fieldRes.propertyKey) || [];
131
+ const params: any[] = [];
132
+ for (const arg of argsMetadata) {
133
+ params[arg.parameterIndex] = args[arg.name];
134
+ }
135
+ const parentParams = Reflect.getMetadata('calyx:parent', resolverInstance, fieldRes.propertyKey) || [];
136
+ for (const idx of parentParams) {
137
+ params[idx] = parent;
138
+ }
139
+ return resolverInstance[fieldRes.propertyKey](...params);
140
+ };
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ if (Object.keys(queryFields).length === 0) {
148
+ return null;
149
+ }
150
+
151
+ return new GraphQLSchema({
152
+ query: new GraphQLObjectType({
153
+ name: 'Query',
154
+ fields: queryFields,
155
+ }),
156
+ ...(Object.keys(mutationFields).length > 0
157
+ ? {
158
+ mutation: new GraphQLObjectType({
159
+ name: 'Mutation',
160
+ fields: mutationFields,
161
+ }),
162
+ }
163
+ : {}),
164
+ });
165
+ }
166
+ }
@@ -0,0 +1,2 @@
1
+ export * from './decorators.ts';
2
+ export * from './graphql.module.ts';
@@ -103,6 +103,8 @@ export class CalyxApplication {
103
103
  private sharedWebSockets: any[] = [];
104
104
  private hasWebSockets = false;
105
105
  private serverPort = 3000;
106
+ private graphqlSchema: any = null;
107
+ private isInitialized = false;
106
108
 
107
109
  use(...middlewares: any[]) {
108
110
  this.globalMiddlewares.push(...middlewares);
@@ -138,6 +140,9 @@ export class CalyxApplication {
138
140
  constructor(private rootModule: any) {}
139
141
 
140
142
  async init() {
143
+ if (this.isInitialized) return;
144
+ this.isInitialized = true;
145
+
141
146
  // Bootstrap the dependency injection container
142
147
  this.container.bootstrap(this.rootModule);
143
148
 
@@ -161,9 +166,18 @@ export class CalyxApplication {
161
166
 
162
167
  // Call OnApplicationBootstrap hooks
163
168
  await this.runOnApplicationBootstrap();
169
+
170
+ // Build GraphQL Schema if GraphQLModule is loaded
171
+ try {
172
+ const { GraphQLModule } = await import('../graphql/graphql.module.ts');
173
+ this.graphqlSchema = GraphQLModule.buildSchema(this.container);
174
+ } catch {
175
+ // ignore
176
+ }
164
177
  }
165
178
 
166
179
  private buildRoutes() {
180
+ this.router.clear();
167
181
  const modules = this.container.getModules();
168
182
  for (const [moduleClass, record] of modules.entries()) {
169
183
  for (const controllerClass of record.controllers) {
@@ -398,6 +412,10 @@ export class CalyxApplication {
398
412
  pathname = pathname.substring(0, queryIdx);
399
413
  }
400
414
 
415
+ if (this.graphqlSchema && pathname === '/graphql' && req.method === 'POST') {
416
+ return this.handleGraphQLRequest(req);
417
+ }
418
+
401
419
  const matched = this.router.match(req.method, pathname);
402
420
  if (!matched) {
403
421
  if (this.globalMiddlewares.length > 0) {
@@ -943,8 +961,36 @@ export class CalyxApplication {
943
961
  );
944
962
  }
945
963
 
964
+ private async handleGraphQLRequest(req: Request): Promise<Response> {
965
+ try {
966
+ const body = await req.json() as any;
967
+ const { query, variables } = body;
968
+
969
+ const { graphql } = await import('graphql');
970
+ const result = await graphql({
971
+ schema: this.graphqlSchema,
972
+ source: query,
973
+ variableValues: variables,
974
+ });
975
+
976
+ return new Response(JSON.stringify(result), {
977
+ status: 200,
978
+ headers: { 'content-type': 'application/json' },
979
+ });
980
+ } catch (err: any) {
981
+ return new Response(
982
+ JSON.stringify({ errors: [{ message: err.message }] }),
983
+ {
984
+ status: 200,
985
+ headers: { 'content-type': 'application/json' },
986
+ }
987
+ );
988
+ }
989
+ }
990
+
946
991
  async listen(port: number): Promise<any> {
947
992
  this.serverPort = port;
993
+ this.buildRoutes();
948
994
  await this.init();
949
995
 
950
996
  const fetchHandler = (req: Request, server: any) => {
@@ -1330,6 +1376,10 @@ export class CalyxApplication {
1330
1376
  // ignore non-json
1331
1377
  }
1332
1378
  }
1379
+
1380
+ getRoutes() {
1381
+ return this.router.getRoutes();
1382
+ }
1333
1383
  }
1334
1384
 
1335
1385
 
@@ -5,6 +5,7 @@ import { MicroserviceOptions } from '../microservices/interfaces.ts';
5
5
  export class CalyxFactory {
6
6
  static async create(rootModule: any): Promise<CalyxApplication> {
7
7
  const app = new CalyxApplication(rootModule);
8
+ await app.init();
8
9
  return app;
9
10
  }
10
11
 
@@ -16,8 +16,21 @@ export class RadixRouter<T> {
16
16
  private staticRoutes = new Map<string, T>();
17
17
  private handlersArray: T[] = [];
18
18
  private compiledMatch: ((method: string, path: string) => RouteMatch<T> | null) | null = null;
19
+ private routesList: { method: string; path: string; handler: T }[] = [];
20
+
21
+ getRoutes() {
22
+ return this.routesList;
23
+ }
24
+
25
+ clear() {
26
+ this.root = new RouterNode<T>();
27
+ this.staticRoutes.clear();
28
+ this.routesList = [];
29
+ this.compiledMatch = null;
30
+ }
19
31
 
20
32
  insert(method: string, path: string, handler: T) {
33
+ this.routesList.push({ method, path, handler });
21
34
  const hasParams = path.includes(':') || path.includes('*');
22
35
  if (!hasParams) {
23
36
  this.staticRoutes.set(method.toUpperCase() + ' ' + path, handler);
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from './websockets/index.ts';
10
10
  export * from './microservices/index.ts';
11
11
  export * from './cache/index.ts';
12
12
  export * from './validation/index.ts';
13
+ export * from './openapi/index.ts';
@@ -0,0 +1,49 @@
1
+ import 'reflect-metadata';
2
+
3
+ export function ApiTags(...tags: string[]): ClassDecorator & MethodDecorator {
4
+ return (target: any, propertyKey?: string | symbol) => {
5
+ const key = 'calyx:api_tags';
6
+ if (propertyKey) {
7
+ Reflect.defineMetadata(key, tags, target, propertyKey);
8
+ } else {
9
+ Reflect.defineMetadata(key, tags, target);
10
+ }
11
+ };
12
+ }
13
+
14
+ export function ApiOperation(options: { summary?: string; description?: string }): MethodDecorator {
15
+ return (target, propertyKey) => {
16
+ Reflect.defineMetadata('calyx:api_operation', options, target, propertyKey);
17
+ };
18
+ }
19
+
20
+ export function ApiResponse(options: {
21
+ status: number;
22
+ description: string;
23
+ type?: any;
24
+ }): MethodDecorator & ClassDecorator {
25
+ return (target: any, propertyKey?: string | symbol) => {
26
+ const key = 'calyx:api_responses';
27
+ if (propertyKey) {
28
+ const existing = Reflect.getOwnMetadata(key, target, propertyKey) || [];
29
+ existing.push(options);
30
+ Reflect.defineMetadata(key, existing, target, propertyKey);
31
+ } else {
32
+ const existing = Reflect.getOwnMetadata(key, target) || [];
33
+ existing.push(options);
34
+ Reflect.defineMetadata(key, existing, target);
35
+ }
36
+ };
37
+ }
38
+
39
+ export function ApiProperty(options: {
40
+ description?: string;
41
+ type?: any;
42
+ required?: boolean;
43
+ } = {}): PropertyDecorator {
44
+ return (target, propertyKey) => {
45
+ const properties = Reflect.getOwnMetadata('calyx:api_properties', target.constructor) || [];
46
+ properties.push({ propertyKey: String(propertyKey), ...options });
47
+ Reflect.defineMetadata('calyx:api_properties', properties, target.constructor);
48
+ };
49
+ }
@@ -0,0 +1,2 @@
1
+ export * from './decorators.ts';
2
+ export * from './swagger.module.ts';
@@ -0,0 +1,174 @@
1
+ import { CalyxApplication } from '../http/application.ts';
2
+
3
+ export class DocumentBuilder {
4
+ private document: any = {
5
+ openapi: '3.0.0',
6
+ info: {
7
+ title: 'Calyx Application',
8
+ version: '1.0.0',
9
+ description: '',
10
+ },
11
+ paths: {},
12
+ components: {
13
+ schemas: {},
14
+ },
15
+ };
16
+
17
+ setTitle(title: string) {
18
+ this.document.info.title = title;
19
+ return this;
20
+ }
21
+
22
+ setVersion(version: string) {
23
+ this.document.info.version = version;
24
+ return this;
25
+ }
26
+
27
+ setDescription(description: string) {
28
+ this.document.info.description = description;
29
+ return this;
30
+ }
31
+
32
+ build() {
33
+ return this.document;
34
+ }
35
+ }
36
+
37
+ export class SwaggerModule {
38
+ static createDocument(app: CalyxApplication, config: any): any {
39
+ const document = { ...config };
40
+ if (!document.paths) document.paths = {};
41
+ if (!document.components) document.components = {};
42
+ if (!document.components.schemas) document.components.schemas = {};
43
+
44
+ const routes = app.getRoutes();
45
+
46
+ for (const route of routes) {
47
+ const { method, path, handler } = route;
48
+
49
+ const swaggerPath = path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
50
+
51
+ if (!document.paths[swaggerPath]) {
52
+ document.paths[swaggerPath] = {};
53
+ }
54
+
55
+ const operationMeta =
56
+ Reflect.getMetadata('calyx:api_operation', handler.controllerClass.prototype, handler.methodName) || {};
57
+ const tags =
58
+ Reflect.getMetadata('calyx:api_tags', handler.controllerClass.prototype, handler.methodName) ||
59
+ Reflect.getMetadata('calyx:api_tags', handler.controllerClass) ||
60
+ [];
61
+ const responsesMeta =
62
+ Reflect.getMetadata('calyx:api_responses', handler.controllerClass.prototype, handler.methodName) || [];
63
+
64
+ const pathParams = [...path.matchAll(/:([a-zA-Z0-9_]+)/g)].map((m) => m[1]);
65
+
66
+ const parameters = pathParams.map((name) => ({
67
+ name,
68
+ in: 'path',
69
+ required: true,
70
+ schema: { type: 'string' },
71
+ }));
72
+
73
+ const responses: Record<string, any> = {};
74
+ if (responsesMeta.length > 0) {
75
+ for (const res of responsesMeta) {
76
+ responses[String(res.status)] = {
77
+ description: res.description,
78
+ };
79
+ if (res.type) {
80
+ const schemaName = res.type.name;
81
+ responses[String(res.status)].content = {
82
+ 'application/json': {
83
+ schema: { $ref: `#/components/schemas/${schemaName}` },
84
+ },
85
+ };
86
+ if (!document.components.schemas[schemaName]) {
87
+ const props = Reflect.getMetadata('calyx:api_properties', res.type) || [];
88
+ const schemaProps: Record<string, any> = {};
89
+ for (const p of props) {
90
+ schemaProps[p.propertyKey] = {
91
+ type: p.type ? p.type.name.toLowerCase() : 'string',
92
+ description: p.description,
93
+ };
94
+ }
95
+ document.components.schemas[schemaName] = {
96
+ type: 'object',
97
+ properties: schemaProps,
98
+ };
99
+ }
100
+ }
101
+ }
102
+ } else {
103
+ responses['200'] = { description: 'OK' };
104
+ }
105
+
106
+ document.paths[swaggerPath][method.toLowerCase()] = {
107
+ summary: operationMeta.summary || '',
108
+ description: operationMeta.description || '',
109
+ tags,
110
+ parameters,
111
+ responses,
112
+ };
113
+ }
114
+
115
+ return document;
116
+ }
117
+
118
+ static setup(path: string, app: CalyxApplication, document: any) {
119
+ const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
120
+ const uiPath = `/${path}`.replace(/\/\/+/g, '/');
121
+
122
+ app.use((req: any, res: any, next: any) => {
123
+ const url = new URL(req.url);
124
+ if (url.pathname === jsonPath && req.method === 'GET') {
125
+ res.status(200);
126
+ res.set('content-type', 'application/json');
127
+ res.send(JSON.stringify(document));
128
+ return;
129
+ }
130
+ next();
131
+ });
132
+
133
+ const html = `
134
+ <!DOCTYPE html>
135
+ <html lang="en">
136
+ <head>
137
+ <meta charset="utf-8" />
138
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
139
+ <title>Calyx Swagger UI</title>
140
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.css" />
141
+ </head>
142
+ <body>
143
+ <div id="swagger-ui"></div>
144
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.js"></script>
145
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-standalone-preset.js"></script>
146
+ <script>
147
+ window.onload = () => {
148
+ window.ui = SwaggerUIBundle({
149
+ url: '${jsonPath}',
150
+ dom_id: '#swagger-ui',
151
+ presets: [
152
+ SwaggerUIBundle.presets.apis,
153
+ SwaggerUIStandalonePreset
154
+ ],
155
+ layout: "BaseLayout"
156
+ });
157
+ };
158
+ </script>
159
+ </body>
160
+ </html>
161
+ `;
162
+
163
+ app.use((req: any, res: any, next: any) => {
164
+ const url = new URL(req.url);
165
+ if (url.pathname === uiPath && req.method === 'GET') {
166
+ res.status(200);
167
+ res.set('content-type', 'text/html');
168
+ res.send(html);
169
+ return;
170
+ }
171
+ next();
172
+ });
173
+ }
174
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { Module, CalyxFactory } from '../src/index.ts';
3
+ import {
4
+ Resolver,
5
+ Query,
6
+ ResolveField,
7
+ Args,
8
+ Parent,
9
+ ObjectType,
10
+ Field,
11
+ GraphQLModule,
12
+ } from '../src/graphql/index.ts';
13
+
14
+ // 1. GraphQL Object Type DTO
15
+ @ObjectType()
16
+ class Author {
17
+ @Field()
18
+ id!: number;
19
+
20
+ @Field()
21
+ name!: string;
22
+ }
23
+
24
+ @ObjectType()
25
+ class PostGql {
26
+ @Field()
27
+ id!: number;
28
+
29
+ @Field()
30
+ title!: string;
31
+
32
+ @Field(() => Author)
33
+ author!: Author;
34
+ }
35
+
36
+ // 2. Resolver Class
37
+ @Resolver(PostGql)
38
+ class PostResolver {
39
+ @Query(() => PostGql)
40
+ getPost(@Args('id') id: number) {
41
+ return {
42
+ id,
43
+ title: `Calyx: GraphQL JIT Performance`,
44
+ authorId: 456, // to be resolved by ResolveField
45
+ };
46
+ }
47
+
48
+ @ResolveField(() => Author)
49
+ author(@Parent() post: any) {
50
+ return {
51
+ id: post.authorId,
52
+ name: 'Jane Doe',
53
+ };
54
+ }
55
+ }
56
+
57
+ @Module({
58
+ imports: [GraphQLModule],
59
+ providers: [PostResolver],
60
+ })
61
+ class TestApp {}
62
+
63
+ describe('Native Code-First GraphQL Module', () => {
64
+ let app: any;
65
+ let baseUrl: string;
66
+ const PORT = 3928;
67
+
68
+ beforeAll(async () => {
69
+ app = await CalyxFactory.create(TestApp);
70
+ await app.listen(PORT);
71
+ baseUrl = `http://localhost:${PORT}`;
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await app.close();
76
+ });
77
+
78
+ test('should execute Query and ResolveField successfully using native graphql adapter', async () => {
79
+ const res = await fetch(`${baseUrl}/graphql`, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({
83
+ query: `
84
+ query {
85
+ getPost(id: 123) {
86
+ id
87
+ title
88
+ author {
89
+ id
90
+ name
91
+ }
92
+ }
93
+ }
94
+ `,
95
+ }),
96
+ });
97
+
98
+ expect(res.status).toBe(200);
99
+ const body = await res.json();
100
+ expect(body.errors).toBeUndefined();
101
+ expect(body.data).toEqual({
102
+ getPost: {
103
+ id: 123,
104
+ title: 'Calyx: GraphQL JIT Performance',
105
+ author: {
106
+ id: 456,
107
+ name: 'Jane Doe',
108
+ },
109
+ },
110
+ });
111
+ });
112
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ Param,
7
+ CalyxFactory,
8
+ ApiTags,
9
+ ApiOperation,
10
+ ApiResponse,
11
+ ApiProperty,
12
+ DocumentBuilder,
13
+ SwaggerModule,
14
+ } from '../src/index.ts';
15
+
16
+ // 1. Model class
17
+ class Item {
18
+ @ApiProperty({ description: 'The unique identifier', type: Number })
19
+ id!: number;
20
+
21
+ @ApiProperty({ description: 'The item name', type: String })
22
+ name!: string;
23
+ }
24
+
25
+ @ApiTags('Items')
26
+ @Controller('items')
27
+ class ItemsController {
28
+ @Get(':id')
29
+ @ApiOperation({ summary: 'Get item by id', description: 'Returns a single item' })
30
+ @ApiResponse({ status: 200, description: 'Item found successfully', type: Item })
31
+ getItem(@Param('id') id: string) {
32
+ return { id: 1, name: 'Gadget' };
33
+ }
34
+ }
35
+
36
+ @Module({
37
+ controllers: [ItemsController],
38
+ })
39
+ class TestApp {}
40
+
41
+ describe('OpenAPI (Swagger) Generation', () => {
42
+ let app: any;
43
+ let baseUrl: string;
44
+ const PORT = 3932;
45
+
46
+ beforeAll(async () => {
47
+ app = await CalyxFactory.create(TestApp);
48
+
49
+ // Build OpenAPI config
50
+ const config = new DocumentBuilder()
51
+ .setTitle('My Test API')
52
+ .setDescription('OpenAPI description')
53
+ .setVersion('2.0.0')
54
+ .build();
55
+
56
+ const document = SwaggerModule.createDocument(app, config);
57
+ SwaggerModule.setup('api', app, document);
58
+
59
+ await app.listen(PORT);
60
+ baseUrl = `http://localhost:${PORT}`;
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await app.close();
65
+ });
66
+
67
+ test('should serve OpenAPI JSON specification with paths and components', async () => {
68
+ const res = await fetch(`${baseUrl}/api-json`);
69
+ expect(res.status).toBe(200);
70
+ const spec = await res.json();
71
+
72
+ expect(spec.openapi).toBe('3.0.0');
73
+ expect(spec.info.title).toBe('My Test API');
74
+ expect(spec.info.version).toBe('2.0.0');
75
+ expect(spec.paths['/items/{id}']).toBeDefined();
76
+
77
+ const getOp = spec.paths['/items/{id}'].get;
78
+ expect(getOp.summary).toBe('Get item by id');
79
+ expect(getOp.tags).toContain('Items');
80
+ expect(getOp.parameters[0].name).toBe('id');
81
+ expect(getOp.parameters[0].in).toBe('path');
82
+
83
+ expect(spec.components.schemas.Item).toBeDefined();
84
+ expect(spec.components.schemas.Item.properties.name.type).toBe('string');
85
+ expect(spec.components.schemas.Item.properties.id.type).toBe('number');
86
+ });
87
+
88
+ test('should serve Swagger UI html wrapper', async () => {
89
+ const res = await fetch(`${baseUrl}/api`);
90
+ expect(res.status).toBe(200);
91
+ const html = await res.text();
92
+ expect(html).toContain('swagger-ui');
93
+ expect(html).toContain('window.ui = SwaggerUIBundle');
94
+ });
95
+ });