@martel/calyx 1.8.0 → 1.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +71 -27
  3. package/benchmarks/graphql-benchmark.ts +81 -0
  4. package/benchmarks/index.ts +32 -0
  5. package/benchmarks/openapi-benchmark.ts +168 -0
  6. package/benchmarks/serialization-benchmark.ts +52 -0
  7. package/benchmarks/techniques-benchmark.ts +84 -0
  8. package/benchmarks/validation-benchmark.ts +74 -0
  9. package/bun.lock +11 -0
  10. package/package.json +7 -6
  11. package/src/cli/index.ts +19 -3
  12. package/src/compression/compression.middleware.ts +7 -0
  13. package/src/cookies/cookies.ts +69 -0
  14. package/src/database/mongoose.module.ts +250 -0
  15. package/src/database/typeorm.module.ts +276 -0
  16. package/src/file-upload/file-upload.interceptor.ts +93 -0
  17. package/src/file-upload/index.ts +1 -0
  18. package/src/graphql/decorators.ts +70 -0
  19. package/src/graphql/graphql.module.ts +401 -57
  20. package/src/http/application.ts +434 -74
  21. package/src/http-client/http-client.module.ts +124 -0
  22. package/src/http-client/index.ts +1 -0
  23. package/src/index.ts +14 -0
  24. package/src/logger/index.ts +1 -0
  25. package/src/logger/logger.service.ts +118 -0
  26. package/src/mvc/index.ts +1 -0
  27. package/src/mvc/mvc.ts +22 -0
  28. package/src/openapi/decorators.ts +154 -0
  29. package/src/openapi/swagger.module.ts +172 -20
  30. package/src/queue/queue.module.ts +174 -0
  31. package/src/session/index.ts +1 -0
  32. package/src/session/session.middleware.ts +82 -0
  33. package/src/sse/index.ts +1 -0
  34. package/src/sse/sse.ts +18 -0
  35. package/src/streaming/index.ts +1 -0
  36. package/src/streaming/streamable-file.ts +32 -0
  37. package/src/validation/pipe.ts +79 -10
  38. package/src/versioning/versioning.ts +46 -0
  39. package/tests/graphql.test.ts +245 -6
  40. package/tests/openapi.test.ts +78 -11
  41. package/tests/techniques.test.ts +471 -0
@@ -0,0 +1,124 @@
1
+ import { Observable } from 'rxjs';
2
+ import { Module, DynamicModule, Injectable } from '../core/decorators.ts';
3
+
4
+ export interface AxiosResponse<T = any> {
5
+ data: T;
6
+ status: number;
7
+ statusText: string;
8
+ headers: any;
9
+ config: any;
10
+ request?: any;
11
+ }
12
+
13
+ @Injectable()
14
+ export class HttpService {
15
+ request<T = any>(config: any): Observable<AxiosResponse<T>> {
16
+ return new Observable((subscriber) => {
17
+ const controller = new AbortController();
18
+ const { url, method = 'GET', data, headers, ...rest } = config;
19
+
20
+ const options: RequestInit = {
21
+ method,
22
+ headers,
23
+ signal: controller.signal,
24
+ ...rest,
25
+ };
26
+
27
+ if (data !== undefined) {
28
+ if (typeof data === 'object') {
29
+ options.body = JSON.stringify(data);
30
+ options.headers = { 'content-type': 'application/json', ...options.headers };
31
+ } else {
32
+ options.body = data;
33
+ }
34
+ }
35
+
36
+ fetch(url, options)
37
+ .then(async (res) => {
38
+ let responseData: any;
39
+ const contentType = res.headers.get('content-type') || '';
40
+ if (contentType.includes('application/json')) {
41
+ try {
42
+ responseData = await res.json();
43
+ } catch {
44
+ responseData = {};
45
+ }
46
+ } else {
47
+ try {
48
+ responseData = await res.text();
49
+ } catch {
50
+ responseData = '';
51
+ }
52
+ }
53
+
54
+ const headersObj: Record<string, string> = {};
55
+ res.headers.forEach((val, key) => {
56
+ headersObj[key] = val;
57
+ });
58
+
59
+ subscriber.next({
60
+ data: responseData,
61
+ status: res.status,
62
+ statusText: res.statusText,
63
+ headers: headersObj,
64
+ config,
65
+ });
66
+ subscriber.complete();
67
+ })
68
+ .catch((err) => {
69
+ subscriber.error(err);
70
+ });
71
+
72
+ return () => {
73
+ controller.abort();
74
+ };
75
+ });
76
+ }
77
+
78
+ get<T = any>(url: string, config?: any): Observable<AxiosResponse<T>> {
79
+ return this.request({ ...config, url, method: 'GET' });
80
+ }
81
+
82
+ post<T = any>(url: string, data?: any, config?: any): Observable<AxiosResponse<T>> {
83
+ return this.request({ ...config, url, data, method: 'POST' });
84
+ }
85
+
86
+ put<T = any>(url: string, data?: any, config?: any): Observable<AxiosResponse<T>> {
87
+ return this.request({ ...config, url, data, method: 'PUT' });
88
+ }
89
+
90
+ delete<T = any>(url: string, config?: any): Observable<AxiosResponse<T>> {
91
+ return this.request({ ...config, url, method: 'DELETE' });
92
+ }
93
+
94
+ patch<T = any>(url: string, data?: any, config?: any): Observable<AxiosResponse<T>> {
95
+ return this.request({ ...config, url, data, method: 'PATCH' });
96
+ }
97
+
98
+ head<T = any>(url: string, config?: any): Observable<AxiosResponse<T>> {
99
+ return this.request({ ...config, url, method: 'HEAD' });
100
+ }
101
+
102
+ options<T = any>(url: string, config?: any): Observable<AxiosResponse<T>> {
103
+ return this.request({ ...config, url, method: 'OPTIONS' });
104
+ }
105
+ }
106
+
107
+ @Module({
108
+ providers: [HttpService],
109
+ exports: [HttpService],
110
+ })
111
+ export class HttpModule {
112
+ static register(options: any = {}): DynamicModule {
113
+ return {
114
+ module: HttpModule,
115
+ providers: [
116
+ {
117
+ provide: HttpService,
118
+ useValue: new HttpService(),
119
+ },
120
+ ],
121
+ exports: [HttpService],
122
+ };
123
+ }
124
+ }
@@ -0,0 +1 @@
1
+ export * from './http-client.module.ts';
package/src/index.ts CHANGED
@@ -11,3 +11,17 @@ export * from './microservices/index.ts';
11
11
  export * from './cache/index.ts';
12
12
  export * from './validation/index.ts';
13
13
  export * from './openapi/index.ts';
14
+ export * from './database/typeorm.module.ts';
15
+ export * from './database/mongoose.module.ts';
16
+ export * from './versioning/versioning.ts';
17
+ export * from './queue/queue.module.ts';
18
+ export * from './logger/index.ts';
19
+ export * from './cookies/cookies.ts';
20
+ export * from './compression/compression.middleware.ts';
21
+ export * from './file-upload/index.ts';
22
+ export * from './streaming/index.ts';
23
+ export * from './http-client/index.ts';
24
+ export * from './session/index.ts';
25
+ export * from './mvc/index.ts';
26
+ export * from './sse/index.ts';
27
+
@@ -0,0 +1 @@
1
+ export * from './logger.service.ts';
@@ -0,0 +1,118 @@
1
+ import { Injectable } from '../core/decorators.ts';
2
+
3
+ export interface LoggerService {
4
+ log(message: any, context?: string): any;
5
+ error(message: any, trace?: string, context?: string): any;
6
+ warn(message: any, context?: string): any;
7
+ debug?(message: any, context?: string): any;
8
+ verbose?(message: any, context?: string): any;
9
+ }
10
+
11
+ export type LogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose';
12
+
13
+ @Injectable()
14
+ export class Logger implements LoggerService {
15
+ private static instance: LoggerService | null = null;
16
+ private static levels: Set<LogLevel> = new Set(['log', 'error', 'warn']);
17
+
18
+ constructor(private context?: string) {}
19
+
20
+ static overrideLogger(logger: LoggerService | null) {
21
+ this.instance = logger;
22
+ }
23
+
24
+ static setLogLevels(levels: LogLevel[]) {
25
+ this.levels = new Set(levels);
26
+ }
27
+
28
+ log(message: any, context?: string) {
29
+ const activeContext = context ?? this.context;
30
+ if (Logger.instance) {
31
+ Logger.instance.log(message, activeContext);
32
+ return;
33
+ }
34
+ if (!Logger.levels.has('log')) return;
35
+ this.print('LOG', message, activeContext);
36
+ }
37
+
38
+ error(message: any, trace?: string, context?: string) {
39
+ const activeContext = context ?? this.context;
40
+ if (Logger.instance) {
41
+ Logger.instance.error(message, trace, activeContext);
42
+ return;
43
+ }
44
+ if (!Logger.levels.has('error')) return;
45
+ this.print('ERROR', message, activeContext, trace);
46
+ }
47
+
48
+ warn(message: any, context?: string) {
49
+ const activeContext = context ?? this.context;
50
+ if (Logger.instance) {
51
+ Logger.instance.warn(message, activeContext);
52
+ return;
53
+ }
54
+ if (!Logger.levels.has('warn')) return;
55
+ this.print('WARN', message, activeContext);
56
+ }
57
+
58
+ debug(message: any, context?: string) {
59
+ const activeContext = context ?? this.context;
60
+ if (Logger.instance) {
61
+ Logger.instance.debug?.(message, activeContext);
62
+ return;
63
+ }
64
+ if (!Logger.levels.has('debug')) return;
65
+ this.print('DEBUG', message, activeContext);
66
+ }
67
+
68
+ verbose(message: any, context?: string) {
69
+ const activeContext = context ?? this.context;
70
+ if (Logger.instance) {
71
+ Logger.instance.verbose?.(message, activeContext);
72
+ return;
73
+ }
74
+ if (!Logger.levels.has('verbose')) return;
75
+ this.print('VERBOSE', message, activeContext);
76
+ }
77
+
78
+ // Static methods
79
+ static log(message: any, context?: string) {
80
+ new Logger(context).log(message);
81
+ }
82
+
83
+ static error(message: any, trace?: string, context?: string) {
84
+ new Logger(context).error(message, trace);
85
+ }
86
+
87
+ static warn(message: any, context?: string) {
88
+ new Logger(context).warn(message);
89
+ }
90
+
91
+ static debug(message: any, context?: string) {
92
+ new Logger(context).debug(message);
93
+ }
94
+
95
+ static verbose(message: any, context?: string) {
96
+ new Logger(context).verbose(message);
97
+ }
98
+
99
+ private print(level: string, message: any, context?: string, trace?: string) {
100
+ const timestamp = new Date().toISOString();
101
+ const formattedContext = context ? ` [${context}]` : '';
102
+ const formattedTrace = trace ? `\n${trace}` : '';
103
+ const msgStr = typeof message === 'object' ? JSON.stringify(message) : String(message);
104
+
105
+ // Using process.stdout.write or console.log/error
106
+ if (level === 'ERROR') {
107
+ console.error(`${timestamp} - \x1b[31m${level}\x1b[0m:${formattedContext} ${msgStr}${formattedTrace}`);
108
+ } else if (level === 'WARN') {
109
+ console.warn(`${timestamp} - \x1b[33m${level}\x1b[0m:${formattedContext} ${msgStr}`);
110
+ } else if (level === 'DEBUG') {
111
+ console.log(`${timestamp} - \x1b[35m${level}\x1b[0m:${formattedContext} ${msgStr}`);
112
+ } else if (level === 'VERBOSE') {
113
+ console.log(`${timestamp} - \x1b[36m${level}\x1b[0m:${formattedContext} ${msgStr}`);
114
+ } else {
115
+ console.log(`${timestamp} - \x1b[32m${level}\x1b[0m:${formattedContext} ${msgStr}`);
116
+ }
117
+ }
118
+ }
@@ -0,0 +1 @@
1
+ export * from './mvc.ts';
package/src/mvc/mvc.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { SetMetadata } from '../core/decorators.ts';
2
+
3
+ export function Render(template: string): MethodDecorator {
4
+ return SetMetadata('calyx:render_template', template);
5
+ }
6
+
7
+ export type ViewEngine = (templatePath: string, data: any) => string | Promise<string>;
8
+
9
+ export async function defaultRenderEngine(templatePath: string, data: any): Promise<string> {
10
+ const file = Bun.file(templatePath);
11
+ if (!(await file.exists())) {
12
+ throw new Error(`Template not found at path: ${templatePath}`);
13
+ }
14
+ let content = await file.text();
15
+ if (data && typeof data === 'object') {
16
+ for (const [key, val] of Object.entries(data)) {
17
+ const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
18
+ content = content.replace(regex, String(val));
19
+ }
20
+ }
21
+ return content;
22
+ }
@@ -47,3 +47,157 @@ export function ApiProperty(options: {
47
47
  Reflect.defineMetadata('calyx:api_properties', properties, target.constructor);
48
48
  };
49
49
  }
50
+
51
+ export function ApiBody(options: { type?: any; required?: boolean; description?: string; schema?: any }): MethodDecorator {
52
+ return (target, propertyKey) => {
53
+ Reflect.defineMetadata('calyx:api_body', options, target, propertyKey);
54
+ };
55
+ }
56
+
57
+ export function ApiQuery(options: { name: string; type?: any; required?: boolean; description?: string }): MethodDecorator {
58
+ return (target, propertyKey) => {
59
+ const queries = Reflect.getOwnMetadata('calyx:api_queries', target, propertyKey) || [];
60
+ queries.push(options);
61
+ Reflect.defineMetadata('calyx:api_queries', queries, target, propertyKey);
62
+ };
63
+ }
64
+
65
+ export function ApiParam(options: { name: string; type?: any; required?: boolean; description?: string }): MethodDecorator {
66
+ return (target, propertyKey) => {
67
+ const params = Reflect.getOwnMetadata('calyx:api_params', target, propertyKey) || [];
68
+ params.push(options);
69
+ Reflect.defineMetadata('calyx:api_params', params, target, propertyKey);
70
+ };
71
+ }
72
+
73
+ export function ApiHeader(options: { name: string; description?: string; required?: boolean }): MethodDecorator {
74
+ return (target, propertyKey) => {
75
+ const headers = Reflect.getOwnMetadata('calyx:api_headers', target, propertyKey) || [];
76
+ headers.push(options);
77
+ Reflect.defineMetadata('calyx:api_headers', headers, target, propertyKey);
78
+ };
79
+ }
80
+
81
+ export function ApiExtraModels(...models: any[]): ClassDecorator {
82
+ return (target) => {
83
+ Reflect.defineMetadata('calyx:api_extra_models', models, target);
84
+ };
85
+ }
86
+
87
+ export function ApiBearerAuth(name = 'bearer'): ClassDecorator & MethodDecorator {
88
+ return (target: any, propertyKey?: string | symbol) => {
89
+ const key = 'calyx:api_security';
90
+ const securityReq = { [name]: [] };
91
+ if (propertyKey) {
92
+ const existing = Reflect.getOwnMetadata(key, target, propertyKey) || [];
93
+ existing.push(securityReq);
94
+ Reflect.defineMetadata(key, existing, target, propertyKey);
95
+ } else {
96
+ const existing = Reflect.getOwnMetadata(key, target) || [];
97
+ existing.push(securityReq);
98
+ Reflect.defineMetadata(key, existing, target);
99
+ }
100
+ };
101
+ }
102
+
103
+ export function ApiBasicAuth(name = 'basic'): ClassDecorator & MethodDecorator {
104
+ return (target: any, propertyKey?: string | symbol) => {
105
+ const key = 'calyx:api_security';
106
+ const securityReq = { [name]: [] };
107
+ if (propertyKey) {
108
+ const existing = Reflect.getOwnMetadata(key, target, propertyKey) || [];
109
+ existing.push(securityReq);
110
+ Reflect.defineMetadata(key, existing, target, propertyKey);
111
+ } else {
112
+ const existing = Reflect.getOwnMetadata(key, target) || [];
113
+ existing.push(securityReq);
114
+ Reflect.defineMetadata(key, existing, target);
115
+ }
116
+ };
117
+ }
118
+
119
+ export function ApiOAuth2(options: any, name = 'oauth2'): ClassDecorator & MethodDecorator {
120
+ return (target: any, propertyKey?: string | symbol) => {
121
+ const key = 'calyx:api_security';
122
+ const securityReq = { [name]: options.scopes || [] };
123
+ if (propertyKey) {
124
+ const existing = Reflect.getOwnMetadata(key, target, propertyKey) || [];
125
+ existing.push(securityReq);
126
+ Reflect.defineMetadata(key, existing, target, propertyKey);
127
+ } else {
128
+ const existing = Reflect.getOwnMetadata(key, target) || [];
129
+ existing.push(securityReq);
130
+ Reflect.defineMetadata(key, existing, target);
131
+ }
132
+ };
133
+ }
134
+
135
+ export function ApiPropertyOptional(options: any = {}): PropertyDecorator {
136
+ return ApiProperty({ ...options, required: false });
137
+ }
138
+
139
+ export function PartialType<T>(classRef: new (...args: any[]) => T): new (...args: any[]) => Partial<T> {
140
+ const properties = Reflect.getMetadata('calyx:api_properties', classRef) || [];
141
+
142
+ class PartialClass {}
143
+ Object.defineProperty(PartialClass, 'name', { value: `Partial${classRef.name}` });
144
+
145
+ const partialProps = properties.map((p: any) => ({ ...p, required: false }));
146
+ Reflect.defineMetadata('calyx:api_properties', partialProps, PartialClass);
147
+
148
+ return PartialClass as any;
149
+ }
150
+
151
+ export function PickType<T, K extends keyof T>(
152
+ classRef: new (...args: any[]) => T,
153
+ keys: readonly K[]
154
+ ): new (...args: any[]) => Pick<T, K> {
155
+ const properties = Reflect.getMetadata('calyx:api_properties', classRef) || [];
156
+ const stringKeys = keys.map(String);
157
+
158
+ class PickClass {}
159
+ Object.defineProperty(PickClass, 'name', { value: `Pick${classRef.name}` });
160
+
161
+ const pickProps = properties.filter((p: any) => stringKeys.includes(p.propertyKey));
162
+ Reflect.defineMetadata('calyx:api_properties', pickProps, PickClass);
163
+
164
+ return PickClass as any;
165
+ }
166
+
167
+ export function OmitType<T, K extends keyof T>(
168
+ classRef: new (...args: any[]) => T,
169
+ keys: readonly K[]
170
+ ): new (...args: any[]) => Omit<T, K> {
171
+ const properties = Reflect.getMetadata('calyx:api_properties', classRef) || [];
172
+ const stringKeys = keys.map(String);
173
+
174
+ class OmitClass {}
175
+ Object.defineProperty(OmitClass, 'name', { value: `Omit${classRef.name}` });
176
+
177
+ const omitProps = properties.filter((p: any) => !stringKeys.includes(p.propertyKey));
178
+ Reflect.defineMetadata('calyx:api_properties', omitProps, OmitClass);
179
+
180
+ return OmitClass as any;
181
+ }
182
+
183
+ export function IntersectionType<A, B>(
184
+ classA: new (...args: any[]) => A,
185
+ classB: new (...args: any[]) => B
186
+ ): new (...args: any[]) => A & B {
187
+ const propsA = Reflect.getMetadata('calyx:api_properties', classA) || [];
188
+ const propsB = Reflect.getMetadata('calyx:api_properties', classB) || [];
189
+
190
+ class IntersectionClass {}
191
+ Object.defineProperty(IntersectionClass, 'name', { value: `${classA.name}And${classB.name}` });
192
+
193
+ const combinedProps = [...propsA];
194
+ for (const pB of propsB) {
195
+ if (!combinedProps.some((pA) => pA.propertyKey === pB.propertyKey)) {
196
+ combinedProps.push(pB);
197
+ }
198
+ }
199
+ Reflect.defineMetadata('calyx:api_properties', combinedProps, IntersectionClass);
200
+
201
+ return IntersectionClass as any;
202
+ }
203
+
@@ -11,6 +11,7 @@ export class DocumentBuilder {
11
11
  paths: {},
12
12
  components: {
13
13
  schemas: {},
14
+ securitySchemes: {},
14
15
  },
15
16
  };
16
17
 
@@ -29,6 +30,26 @@ export class DocumentBuilder {
29
30
  return this;
30
31
  }
31
32
 
33
+ addBearerAuth(options: any = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, name = 'bearer') {
34
+ this.document.components.securitySchemes[name] = options;
35
+ return this;
36
+ }
37
+
38
+ addBasicAuth(options: any = { type: 'http', scheme: 'basic' }, name = 'basic') {
39
+ this.document.components.securitySchemes[name] = options;
40
+ return this;
41
+ }
42
+
43
+ addOAuth2(options: any = { type: 'oauth2', flows: {} }, name = 'oauth2') {
44
+ this.document.components.securitySchemes[name] = options;
45
+ return this;
46
+ }
47
+
48
+ addSecurity(name: string, scheme: any) {
49
+ this.document.components.securitySchemes[name] = scheme;
50
+ return this;
51
+ }
52
+
32
53
  build() {
33
54
  return this.document;
34
55
  }
@@ -40,9 +61,42 @@ export class SwaggerModule {
40
61
  if (!document.paths) document.paths = {};
41
62
  if (!document.components) document.components = {};
42
63
  if (!document.components.schemas) document.components.schemas = {};
64
+ if (!document.components.securitySchemes) {
65
+ document.components.securitySchemes = config.components?.securitySchemes || {};
66
+ }
43
67
 
44
68
  const routes = app.getRoutes();
45
69
 
70
+ function registerSchema(typeClass: any) {
71
+ if (!typeClass || typeof typeClass !== 'function') return;
72
+ const schemaName = typeClass.name;
73
+ if (document.components.schemas[schemaName]) return;
74
+
75
+ const props = Reflect.getMetadata('calyx:api_properties', typeClass) || [];
76
+ const schemaProps: Record<string, any> = {};
77
+ const requiredProps: string[] = [];
78
+
79
+ for (const p of props) {
80
+ let pType = 'string';
81
+ if (p.type) {
82
+ pType = p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase();
83
+ }
84
+ schemaProps[p.propertyKey] = {
85
+ type: pType === 'number' || pType === 'boolean' || pType === 'object' || pType === 'array' ? pType : 'string',
86
+ description: p.description,
87
+ };
88
+ if (p.required) {
89
+ requiredProps.push(p.propertyKey);
90
+ }
91
+ }
92
+
93
+ document.components.schemas[schemaName] = {
94
+ type: 'object',
95
+ properties: schemaProps,
96
+ ...(requiredProps.length > 0 ? { required: requiredProps } : {}),
97
+ };
98
+ }
99
+
46
100
  for (const route of routes) {
47
101
  const { method, path, handler } = route;
48
102
 
@@ -60,15 +114,124 @@ export class SwaggerModule {
60
114
  [];
61
115
  const responsesMeta =
62
116
  Reflect.getMetadata('calyx:api_responses', handler.controllerClass.prototype, handler.methodName) || [];
117
+ const securityMeta =
118
+ Reflect.getMetadata('calyx:api_security', handler.controllerClass.prototype, handler.methodName) ||
119
+ Reflect.getMetadata('calyx:api_security', handler.controllerClass) ||
120
+ [];
121
+
122
+ // Extra Models
123
+ const extraModels = Reflect.getMetadata('calyx:api_extra_models', handler.controllerClass) || [];
124
+ for (const model of extraModels) {
125
+ registerSchema(model);
126
+ }
127
+
128
+ const parameters: any[] = [];
63
129
 
130
+ // Parse Path Params (Regex match)
64
131
  const pathParams = [...path.matchAll(/:([a-zA-Z0-9_]+)/g)].map((m) => m[1]);
132
+ const pathParamDecorators = Reflect.getMetadata('calyx:api_params', handler.controllerClass.prototype, handler.methodName) || [];
133
+
134
+ for (const name of pathParams) {
135
+ const dec = pathParamDecorators.find((d: any) => d.name === name) || {};
136
+ parameters.push({
137
+ name,
138
+ in: 'path',
139
+ required: true,
140
+ description: dec.description || '',
141
+ schema: {
142
+ type: dec.type ? dec.type.name.toLowerCase() : 'string',
143
+ },
144
+ });
145
+ }
146
+
147
+ // Parse Http Parameter Decorators (@Query, @Headers, etc.)
148
+ const httpParams: any[] = Reflect.getMetadata('calyx:http_params', handler.controllerClass.prototype, handler.methodName) || [];
149
+ const paramTypes = Reflect.getMetadata('design:paramtypes', handler.controllerClass.prototype, handler.methodName) || [];
150
+ const apiQueries = Reflect.getMetadata('calyx:api_queries', handler.controllerClass.prototype, handler.methodName) || [];
151
+ const apiHeaders = Reflect.getMetadata('calyx:api_headers', handler.controllerClass.prototype, handler.methodName) || [];
152
+ let requestBody: any = undefined;
153
+
154
+ for (const param of httpParams) {
155
+ const paramType = paramTypes[param.index];
156
+
157
+ if (param.type === 'query') {
158
+ const qName = param.name;
159
+ if (qName) {
160
+ const dec = apiQueries.find((q: any) => q.name === qName) || {};
161
+ parameters.push({
162
+ name: qName,
163
+ in: 'query',
164
+ required: dec.required ?? false,
165
+ description: dec.description || '',
166
+ schema: {
167
+ type: dec.type ? dec.type.name.toLowerCase() : (paramType ? paramType.name.toLowerCase() : 'string'),
168
+ },
169
+ });
170
+ }
171
+ } else if (param.type === 'headers') {
172
+ const hName = param.name;
173
+ if (hName) {
174
+ const dec = apiHeaders.find((h: any) => h.name === hName) || {};
175
+ parameters.push({
176
+ name: hName,
177
+ in: 'header',
178
+ required: dec.required ?? false,
179
+ description: dec.description || '',
180
+ schema: {
181
+ type: 'string',
182
+ },
183
+ });
184
+ }
185
+ } else if (param.type === 'body') {
186
+ const bodyDec = Reflect.getMetadata('calyx:api_body', handler.controllerClass.prototype, handler.methodName) || {};
187
+ const targetType = bodyDec.type || paramType;
188
+
189
+ if (targetType) {
190
+ registerSchema(targetType);
191
+ requestBody = {
192
+ description: bodyDec.description || '',
193
+ required: bodyDec.required ?? true,
194
+ content: {
195
+ 'application/json': {
196
+ schema: {
197
+ $ref: `#/components/schemas/${targetType.name}`,
198
+ },
199
+ },
200
+ },
201
+ };
202
+ }
203
+ }
204
+ }
205
+
206
+ // Add manual @ApiQuery annotations if they weren't matched to method params
207
+ for (const q of apiQueries) {
208
+ if (!parameters.some((p) => p.in === 'query' && p.name === q.name)) {
209
+ parameters.push({
210
+ name: q.name,
211
+ in: 'query',
212
+ required: q.required ?? false,
213
+ description: q.description || '',
214
+ schema: {
215
+ type: q.type ? q.type.name.toLowerCase() : 'string',
216
+ },
217
+ });
218
+ }
219
+ }
65
220
 
66
- const parameters = pathParams.map((name) => ({
67
- name,
68
- in: 'path',
69
- required: true,
70
- schema: { type: 'string' },
71
- }));
221
+ // Add manual @ApiHeader annotations if they weren't matched to method params
222
+ for (const h of apiHeaders) {
223
+ if (!parameters.some((p) => p.in === 'header' && p.name === h.name)) {
224
+ parameters.push({
225
+ name: h.name,
226
+ in: 'header',
227
+ required: h.required ?? false,
228
+ description: h.description || '',
229
+ schema: {
230
+ type: 'string',
231
+ },
232
+ });
233
+ }
234
+ }
72
235
 
73
236
  const responses: Record<string, any> = {};
74
237
  if (responsesMeta.length > 0) {
@@ -83,20 +246,7 @@ export class SwaggerModule {
83
246
  schema: { $ref: `#/components/schemas/${schemaName}` },
84
247
  },
85
248
  };
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
- }
249
+ registerSchema(res.type);
100
250
  }
101
251
  }
102
252
  } else {
@@ -108,7 +258,9 @@ export class SwaggerModule {
108
258
  description: operationMeta.description || '',
109
259
  tags,
110
260
  parameters,
261
+ ...(requestBody ? { requestBody } : {}),
111
262
  responses,
263
+ ...(securityMeta.length > 0 ? { security: securityMeta } : {}),
112
264
  };
113
265
  }
114
266