@martel/calyx 1.6.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 +14 -0
- package/bun.lock +3 -0
- package/package.json +2 -1
- package/src/cache/cache.interceptor.ts +32 -0
- package/src/cache/cache.module.ts +31 -0
- package/src/cache/cache.service.ts +86 -0
- package/src/cache/index.ts +3 -0
- 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 +60 -0
- package/src/http/factory.ts +1 -0
- package/src/http/router.ts +13 -0
- package/src/index.ts +3 -0
- package/src/openapi/decorators.ts +49 -0
- package/src/openapi/index.ts +2 -0
- package/src/openapi/swagger.module.ts +174 -0
- package/src/validation/compiler.ts +124 -0
- package/src/validation/decorators.ts +47 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/pipe.ts +31 -0
- package/tests/cache.test.ts +93 -0
- package/tests/graphql.test.ts +112 -0
- package/tests/openapi.test.ts +95 -0
- package/tests/validation-serialization.test.ts +134 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
# [1.7.0](https://github.com/bmartel/calyx/compare/v1.6.0...v1.7.0) (2026-07-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **cache,validation:** implement SQLite CacheModule, JIT ValidationPipe, and JIT Response Serializer ([1d37b68](https://github.com/bmartel/calyx/commit/1d37b68c2a7ba52e7878810b63f75ee31becc433))
|
|
14
|
+
|
|
1
15
|
# [1.6.0](https://github.com/bmartel/calyx/compare/v1.5.0...v1.6.0) (2026-07-01)
|
|
2
16
|
|
|
3
17
|
|
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,32 @@
|
|
|
1
|
+
import { NestInterceptor, ExecutionContext, CallHandler } from '../lifecycle/interfaces.ts';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
import { CacheService } from './cache.service.ts';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class CacheInterceptor implements NestInterceptor {
|
|
7
|
+
constructor(private readonly cacheService: CacheService) {}
|
|
8
|
+
|
|
9
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
|
|
10
|
+
const type = context.getType();
|
|
11
|
+
if (type !== 'http') {
|
|
12
|
+
return next.handle();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
16
|
+
if (req.method !== 'GET') {
|
|
17
|
+
return next.handle();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = new URL(req.url);
|
|
21
|
+
const key = `http_cache::${url.pathname}${url.search}`;
|
|
22
|
+
|
|
23
|
+
const cached = await this.cacheService.get(key);
|
|
24
|
+
if (cached !== undefined) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await next.handle();
|
|
29
|
+
await this.cacheService.set(key, result);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
2
|
+
import { CacheService } from './cache.service.ts';
|
|
3
|
+
import { CacheInterceptor } from './cache.interceptor.ts';
|
|
4
|
+
|
|
5
|
+
export interface CacheModuleOptions {
|
|
6
|
+
dbPath?: string;
|
|
7
|
+
defaultTtl?: number;
|
|
8
|
+
isGlobal?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Module({
|
|
12
|
+
providers: [CacheService, CacheInterceptor],
|
|
13
|
+
exports: [CacheService, CacheInterceptor],
|
|
14
|
+
})
|
|
15
|
+
export class CacheModule {
|
|
16
|
+
static register(options: CacheModuleOptions = {}): DynamicModule {
|
|
17
|
+
const cacheServiceInstance = new CacheService(options);
|
|
18
|
+
|
|
19
|
+
const cacheServiceProvider = {
|
|
20
|
+
provide: CacheService,
|
|
21
|
+
useValue: cacheServiceInstance,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
module: CacheModule,
|
|
26
|
+
providers: [cacheServiceProvider, CacheInterceptor],
|
|
27
|
+
exports: [CacheService, CacheInterceptor],
|
|
28
|
+
global: options.isGlobal ?? false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class CacheService {
|
|
6
|
+
private db!: Database;
|
|
7
|
+
private pruneTimer: any;
|
|
8
|
+
private readonly dbPath: string;
|
|
9
|
+
private readonly defaultTtl: number;
|
|
10
|
+
|
|
11
|
+
constructor(options: { dbPath?: string; defaultTtl?: number } = {}) {
|
|
12
|
+
this.dbPath = options.dbPath ?? ':memory:';
|
|
13
|
+
this.defaultTtl = options.defaultTtl ?? 5;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
onModuleInit() {
|
|
17
|
+
this.db = new Database(this.dbPath);
|
|
18
|
+
this.db.run(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT,
|
|
22
|
+
expires_at INTEGER
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_expires_at ON cache (expires_at)`);
|
|
26
|
+
|
|
27
|
+
this.pruneTimer = setInterval(() => this.prune(), 30000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onModuleDestroy() {
|
|
31
|
+
if (this.pruneTimer) {
|
|
32
|
+
clearInterval(this.pruneTimer);
|
|
33
|
+
}
|
|
34
|
+
if (this.db) {
|
|
35
|
+
this.db.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async get<T = any>(key: string): Promise<T | undefined> {
|
|
40
|
+
const row = this.db.query('SELECT value, expires_at FROM cache WHERE key = $key').get({ $key: key }) as any;
|
|
41
|
+
if (!row) return undefined;
|
|
42
|
+
|
|
43
|
+
if (row.expires_at !== null && row.expires_at < Date.now()) {
|
|
44
|
+
this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(row.value) as T;
|
|
50
|
+
} catch {
|
|
51
|
+
return row.value as unknown as T;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async set<T = any>(key: string, value: T, ttl?: number): Promise<void> {
|
|
56
|
+
const ttlSeconds = ttl !== undefined ? ttl : this.defaultTtl;
|
|
57
|
+
const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
|
|
58
|
+
const valStr = JSON.stringify(value);
|
|
59
|
+
|
|
60
|
+
this.db.query(`
|
|
61
|
+
INSERT INTO cache (key, value, expires_at)
|
|
62
|
+
VALUES ($key, $val, $expiresAt)
|
|
63
|
+
ON CONFLICT(key) DO UPDATE SET value = $val, expires_at = $expiresAt
|
|
64
|
+
`).run({
|
|
65
|
+
$key: key,
|
|
66
|
+
$val: valStr,
|
|
67
|
+
$expiresAt: expiresAt,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async del(key: string): Promise<void> {
|
|
72
|
+
this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async reset(): Promise<void> {
|
|
76
|
+
this.db.run('DELETE FROM cache');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private prune() {
|
|
80
|
+
try {
|
|
81
|
+
this.db.query('DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < $now').run({ $now: Date.now() });
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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
|
@@ -10,6 +10,7 @@ import { EventEmitter } from '../event-emitter/event-emitter.ts';
|
|
|
10
10
|
import { cors, CorsOptions } from '../security/cors.middleware.ts';
|
|
11
11
|
import { helmet, HelmetOptions } from '../security/helmet.middleware.ts';
|
|
12
12
|
import { CronMatcher } from '../schedule/cron.matcher.ts';
|
|
13
|
+
import { SerializationCompiler } from '../validation/compiler.ts';
|
|
13
14
|
|
|
14
15
|
class ObjectPool<T> {
|
|
15
16
|
private pool: T[] = [];
|
|
@@ -102,6 +103,8 @@ export class CalyxApplication {
|
|
|
102
103
|
private sharedWebSockets: any[] = [];
|
|
103
104
|
private hasWebSockets = false;
|
|
104
105
|
private serverPort = 3000;
|
|
106
|
+
private graphqlSchema: any = null;
|
|
107
|
+
private isInitialized = false;
|
|
105
108
|
|
|
106
109
|
use(...middlewares: any[]) {
|
|
107
110
|
this.globalMiddlewares.push(...middlewares);
|
|
@@ -137,6 +140,9 @@ export class CalyxApplication {
|
|
|
137
140
|
constructor(private rootModule: any) {}
|
|
138
141
|
|
|
139
142
|
async init() {
|
|
143
|
+
if (this.isInitialized) return;
|
|
144
|
+
this.isInitialized = true;
|
|
145
|
+
|
|
140
146
|
// Bootstrap the dependency injection container
|
|
141
147
|
this.container.bootstrap(this.rootModule);
|
|
142
148
|
|
|
@@ -160,9 +166,18 @@ export class CalyxApplication {
|
|
|
160
166
|
|
|
161
167
|
// Call OnApplicationBootstrap hooks
|
|
162
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
|
+
}
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
private buildRoutes() {
|
|
180
|
+
this.router.clear();
|
|
166
181
|
const modules = this.container.getModules();
|
|
167
182
|
for (const [moduleClass, record] of modules.entries()) {
|
|
168
183
|
for (const controllerClass of record.controllers) {
|
|
@@ -397,6 +412,10 @@ export class CalyxApplication {
|
|
|
397
412
|
pathname = pathname.substring(0, queryIdx);
|
|
398
413
|
}
|
|
399
414
|
|
|
415
|
+
if (this.graphqlSchema && pathname === '/graphql' && req.method === 'POST') {
|
|
416
|
+
return this.handleGraphQLRequest(req);
|
|
417
|
+
}
|
|
418
|
+
|
|
400
419
|
const matched = this.router.match(req.method, pathname);
|
|
401
420
|
if (!matched) {
|
|
402
421
|
if (this.globalMiddlewares.length > 0) {
|
|
@@ -888,6 +907,15 @@ export class CalyxApplication {
|
|
|
888
907
|
|
|
889
908
|
if (typeof result === 'object') {
|
|
890
909
|
responseHeaders['content-type'] = 'application/json';
|
|
910
|
+
const constructor = result.constructor;
|
|
911
|
+
if (constructor) {
|
|
912
|
+
const hasRules = Reflect.hasMetadata('calyx:validation_rules', constructor);
|
|
913
|
+
const hasExpose = Reflect.hasMetadata('calyx:expose_properties', constructor);
|
|
914
|
+
if (hasRules || hasExpose) {
|
|
915
|
+
const serialize = SerializationCompiler.compile(constructor);
|
|
916
|
+
return new Response(serialize(result), { status, headers: responseHeaders });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
891
919
|
return new Response(JSON.stringify(result), { status, headers: responseHeaders });
|
|
892
920
|
}
|
|
893
921
|
|
|
@@ -933,8 +961,36 @@ export class CalyxApplication {
|
|
|
933
961
|
);
|
|
934
962
|
}
|
|
935
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
|
+
|
|
936
991
|
async listen(port: number): Promise<any> {
|
|
937
992
|
this.serverPort = port;
|
|
993
|
+
this.buildRoutes();
|
|
938
994
|
await this.init();
|
|
939
995
|
|
|
940
996
|
const fetchHandler = (req: Request, server: any) => {
|
|
@@ -1320,6 +1376,10 @@ export class CalyxApplication {
|
|
|
1320
1376
|
// ignore non-json
|
|
1321
1377
|
}
|
|
1322
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
getRoutes() {
|
|
1381
|
+
return this.router.getRoutes();
|
|
1382
|
+
}
|
|
1323
1383
|
}
|
|
1324
1384
|
|
|
1325
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
|
@@ -8,3 +8,6 @@ export * from './security/index.ts';
|
|
|
8
8
|
export * from './schedule/index.ts';
|
|
9
9
|
export * from './websockets/index.ts';
|
|
10
10
|
export * from './microservices/index.ts';
|
|
11
|
+
export * from './cache/index.ts';
|
|
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
|
+
}
|