@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 +7 -0
- package/bun.lock +3 -0
- package/package.json +2 -1
- package/src/graphql/decorators.ts +62 -0
- package/src/graphql/graphql.module.ts +166 -0
- package/src/graphql/index.ts +2 -0
- package/src/http/application.ts +50 -0
- package/src/http/factory.ts +1 -0
- package/src/http/router.ts +13 -0
- package/src/index.ts +1 -0
- package/src/openapi/decorators.ts +49 -0
- package/src/openapi/index.ts +2 -0
- package/src/openapi/swagger.module.ts +174 -0
- package/tests/graphql.test.ts +112 -0
- package/tests/openapi.test.ts +95 -0
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.
|
|
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
|
+
}
|
package/src/http/application.ts
CHANGED
|
@@ -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
|
|
package/src/http/factory.ts
CHANGED
package/src/http/router.ts
CHANGED
|
@@ -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
|
@@ -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,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
|
+
});
|