@martel/calyx 1.7.0 → 1.9.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 (45) 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 +14 -0
  10. package/package.json +8 -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 +132 -0
  19. package/src/graphql/graphql.module.ts +316 -0
  20. package/src/graphql/index.ts +2 -0
  21. package/src/http/application.ts +380 -70
  22. package/src/http/factory.ts +1 -0
  23. package/src/http/router.ts +13 -0
  24. package/src/http-client/http-client.module.ts +124 -0
  25. package/src/http-client/index.ts +1 -0
  26. package/src/index.ts +15 -0
  27. package/src/logger/index.ts +1 -0
  28. package/src/logger/logger.service.ts +118 -0
  29. package/src/mvc/index.ts +1 -0
  30. package/src/mvc/mvc.ts +22 -0
  31. package/src/openapi/decorators.ts +203 -0
  32. package/src/openapi/index.ts +2 -0
  33. package/src/openapi/swagger.module.ts +326 -0
  34. package/src/queue/queue.module.ts +174 -0
  35. package/src/session/index.ts +1 -0
  36. package/src/session/session.middleware.ts +82 -0
  37. package/src/sse/index.ts +1 -0
  38. package/src/sse/sse.ts +18 -0
  39. package/src/streaming/index.ts +1 -0
  40. package/src/streaming/streamable-file.ts +32 -0
  41. package/src/validation/pipe.ts +79 -10
  42. package/src/versioning/versioning.ts +46 -0
  43. package/tests/graphql.test.ts +176 -0
  44. package/tests/openapi.test.ts +162 -0
  45. package/tests/techniques.test.ts +471 -0
@@ -3,25 +3,94 @@ import { Injectable } from '../core/decorators.ts';
3
3
  import { HttpException } from '../http/exceptions.ts';
4
4
  import { ValidationCompiler } from './compiler.ts';
5
5
 
6
+ // Dynamic detection of class-validator and class-transformer
7
+ let classValidator: any = null;
8
+ let classTransformer: any = null;
9
+ try {
10
+ require.resolve('class-validator');
11
+ require.resolve('class-transformer');
12
+ classValidator = require('class-validator');
13
+ classTransformer = require('class-transformer');
14
+ } catch {
15
+ // ignore
16
+ }
17
+
18
+ export interface ValidationPipeOptions {
19
+ transform?: boolean;
20
+ disableErrorMessages?: boolean;
21
+ whitelist?: boolean;
22
+ forbidNonWhitelisted?: boolean;
23
+ groups?: string[];
24
+ dismissDefaultMessages?: boolean;
25
+ validationError?: {
26
+ target?: boolean;
27
+ value?: boolean;
28
+ };
29
+ }
30
+
6
31
  @Injectable()
7
32
  export class ValidationPipe implements PipeTransform {
33
+ private transformOption: boolean;
34
+ private whitelistOption: boolean;
35
+ private forbidNonWhitelistedOption: boolean;
36
+
37
+ constructor(options: ValidationPipeOptions = {}) {
38
+ this.transformOption = options.transform ?? true;
39
+ this.whitelistOption = options.whitelist ?? false;
40
+ this.forbidNonWhitelistedOption = options.forbidNonWhitelisted ?? false;
41
+ }
42
+
8
43
  async transform(value: any, metadata: ArgumentMetadata) {
9
44
  const metatype = metadata.metatype;
10
45
  if (!metatype || this.toValidate(metatype)) {
11
46
  return value;
12
47
  }
13
48
 
14
- const validate = ValidationCompiler.compile(metatype);
15
- const errors = validate(value);
16
- if (errors) {
17
- throw new HttpException({
18
- statusCode: 400,
19
- message: 'Validation failed',
20
- errors,
21
- }, 400);
22
- }
49
+ if (classValidator && classTransformer) {
50
+ try {
51
+ const object = classTransformer.plainToInstance(metatype, value);
52
+ const errors = await classValidator.validate(object, {
53
+ whitelist: this.whitelistOption,
54
+ forbidNonWhitelisted: this.forbidNonWhitelistedOption,
55
+ });
56
+
57
+ if (errors.length > 0) {
58
+ const errorMessages = errors.flatMap((err: any) => {
59
+ return err.constraints ? Object.values(err.constraints) : [];
60
+ });
61
+ throw new HttpException({
62
+ statusCode: 400,
63
+ message: 'Validation failed',
64
+ errors: errorMessages,
65
+ }, 400);
66
+ }
23
67
 
24
- return value;
68
+ return this.transformOption ? object : value;
69
+ } catch (err) {
70
+ // Fallback to JIT if dynamic call fails
71
+ const validate = ValidationCompiler.compile(metatype);
72
+ const errors = validate(value);
73
+ if (errors) {
74
+ throw new HttpException({
75
+ statusCode: 400,
76
+ message: 'Validation failed',
77
+ errors,
78
+ }, 400);
79
+ }
80
+ return value;
81
+ }
82
+ } else {
83
+ const validate = ValidationCompiler.compile(metatype);
84
+ const errors = validate(value);
85
+ if (errors) {
86
+ throw new HttpException({
87
+ statusCode: 400,
88
+ message: 'Validation failed',
89
+ errors,
90
+ }, 400);
91
+ }
92
+ return value;
93
+ }
25
94
  }
26
95
 
27
96
  private toValidate(metatype: Function): boolean {
@@ -0,0 +1,46 @@
1
+ import 'reflect-metadata';
2
+ import { SetMetadata } from '../core/decorators.ts';
3
+
4
+ export enum VersioningType {
5
+ URI = 'uri',
6
+ HEADER = 'header',
7
+ MEDIA_TYPE = 'media-type',
8
+ }
9
+
10
+ export interface VersioningOptions {
11
+ type: VersioningType;
12
+ defaultVersion?: string | string[];
13
+ header?: string; // For HEADER type, e.g., 'X-API-Version'
14
+ key?: string; // For MEDIA_TYPE type, e.g., 'v'
15
+ }
16
+
17
+ export const VERSION_METADATA_KEY = 'calyx:version';
18
+
19
+ export function Version(version: string | string[]): MethodDecorator & ClassDecorator {
20
+ return (target: any, key?: string | symbol, descriptor?: any) => {
21
+ if (descriptor) {
22
+ Reflect.defineMetadata(VERSION_METADATA_KEY, version, descriptor.value);
23
+ return descriptor;
24
+ }
25
+ Reflect.defineMetadata(VERSION_METADATA_KEY, version, target);
26
+ return target;
27
+ };
28
+ }
29
+
30
+ export class VersionExtractor {
31
+ static extract(req: Request, type: VersioningType, options: VersioningOptions): string | undefined {
32
+ if (type === VersioningType.HEADER) {
33
+ const headerName = options.header ?? 'x-api-version';
34
+ return req.headers.get(headerName.toLowerCase()) || undefined;
35
+ }
36
+
37
+ if (type === VersioningType.MEDIA_TYPE) {
38
+ const accept = req.headers.get('accept') || '';
39
+ const key = options.key ?? 'v';
40
+ const match = accept.match(new RegExp(`${key}=([a-zA-Z0-9_-]+)`));
41
+ return match ? match[1] : undefined;
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+ }
@@ -0,0 +1,176 @@
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
+ Mutation,
7
+ ResolveField,
8
+ Args,
9
+ Parent,
10
+ ObjectType,
11
+ InputType,
12
+ ArgsType,
13
+ Field,
14
+ GraphQLModule,
15
+ } from '../src/graphql/index.ts';
16
+
17
+ // 1. GraphQL Object Type DTOs
18
+ @ObjectType()
19
+ class Author {
20
+ @Field()
21
+ id!: number;
22
+
23
+ @Field()
24
+ name!: string;
25
+ }
26
+
27
+ @ObjectType()
28
+ class PostGql {
29
+ @Field()
30
+ id!: number;
31
+
32
+ @Field()
33
+ title!: string;
34
+
35
+ @Field(() => Author)
36
+ author!: Author;
37
+ }
38
+
39
+ // 2. GraphQL Input Type DTO
40
+ @InputType()
41
+ class CreatePostInput {
42
+ @Field()
43
+ title!: string;
44
+
45
+ @Field()
46
+ authorId!: number;
47
+ }
48
+
49
+ // 3. GraphQL Args Type DTO (Flattened Args)
50
+ @ArgsType()
51
+ class GetPostArgs {
52
+ @Field()
53
+ id!: number;
54
+ }
55
+
56
+ // 4. Resolver Class
57
+ @Resolver(PostGql)
58
+ class PostResolver {
59
+ @Query(() => PostGql)
60
+ getPost(@Args() args: GetPostArgs) {
61
+ return {
62
+ id: args.id,
63
+ title: `Calyx: GraphQL JIT Performance`,
64
+ authorId: 456, // to be resolved by ResolveField
65
+ };
66
+ }
67
+
68
+ @Mutation(() => PostGql)
69
+ createPost(@Args('input') input: CreatePostInput) {
70
+ return {
71
+ id: 999,
72
+ title: input.title,
73
+ authorId: input.authorId,
74
+ };
75
+ }
76
+
77
+ @ResolveField(() => Author)
78
+ author(@Parent() post: any) {
79
+ return {
80
+ id: post.authorId,
81
+ name: 'Jane Doe',
82
+ };
83
+ }
84
+ }
85
+
86
+ @Module({
87
+ imports: [GraphQLModule],
88
+ providers: [PostResolver],
89
+ })
90
+ class TestApp {}
91
+
92
+ describe('Native Code-First GraphQL Module', () => {
93
+ let app: any;
94
+ let baseUrl: string;
95
+ const PORT = 3928;
96
+
97
+ beforeAll(async () => {
98
+ app = await CalyxFactory.create(TestApp);
99
+ await app.listen(PORT);
100
+ baseUrl = `http://localhost:${PORT}`;
101
+ });
102
+
103
+ afterAll(async () => {
104
+ await app.close();
105
+ });
106
+
107
+ test('should execute Query and ResolveField successfully using native graphql adapter', async () => {
108
+ const res = await fetch(`${baseUrl}/graphql`, {
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/json' },
111
+ body: JSON.stringify({
112
+ query: `
113
+ query {
114
+ getPost(id: 123) {
115
+ id
116
+ title
117
+ author {
118
+ id
119
+ name
120
+ }
121
+ }
122
+ }
123
+ `,
124
+ }),
125
+ });
126
+
127
+ expect(res.status).toBe(200);
128
+ const body = await res.json();
129
+ expect(body.errors).toBeUndefined();
130
+ expect(body.data).toEqual({
131
+ getPost: {
132
+ id: 123,
133
+ title: 'Calyx: GraphQL JIT Performance',
134
+ author: {
135
+ id: 456,
136
+ name: 'Jane Doe',
137
+ },
138
+ },
139
+ });
140
+ });
141
+
142
+ test('should execute Mutation with InputType successfully', async () => {
143
+ const res = await fetch(`${baseUrl}/graphql`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/json' },
146
+ body: JSON.stringify({
147
+ query: `
148
+ mutation {
149
+ createPost(input: { title: "Calyx Rocks", authorId: 789 }) {
150
+ id
151
+ title
152
+ author {
153
+ id
154
+ name
155
+ }
156
+ }
157
+ }
158
+ `,
159
+ }),
160
+ });
161
+
162
+ expect(res.status).toBe(200);
163
+ const body = await res.json();
164
+ expect(body.errors).toBeUndefined();
165
+ expect(body.data).toEqual({
166
+ createPost: {
167
+ id: 999,
168
+ title: 'Calyx Rocks',
169
+ author: {
170
+ id: 789,
171
+ name: 'Jane Doe',
172
+ },
173
+ },
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ Post,
7
+ Param,
8
+ Query,
9
+ Body,
10
+ CalyxFactory,
11
+ ApiTags,
12
+ ApiOperation,
13
+ ApiResponse,
14
+ ApiProperty,
15
+ ApiBody,
16
+ ApiQuery,
17
+ ApiHeader,
18
+ ApiBearerAuth,
19
+ DocumentBuilder,
20
+ SwaggerModule,
21
+ PartialType,
22
+ } from '../src/index.ts';
23
+
24
+ // 1. DTO Model
25
+ class Item {
26
+ @ApiProperty({ description: 'The unique identifier', type: Number })
27
+ id!: number;
28
+
29
+ @ApiProperty({ description: 'The item name', type: String })
30
+ name!: string;
31
+ }
32
+
33
+ // 2. Request Body DTO
34
+ class CreateItemDto {
35
+ @ApiProperty({ description: 'The name of the item', type: String, required: true })
36
+ name!: string;
37
+
38
+ @ApiProperty({ description: 'The item price', type: Number, required: true })
39
+ price!: number;
40
+ }
41
+
42
+ // 3. Partial DTO using mapped type
43
+ class UpdateItemDto extends PartialType(CreateItemDto) {}
44
+
45
+ @ApiTags('Items')
46
+ @ApiBearerAuth('jwt')
47
+ @Controller('items')
48
+ class ItemsController {
49
+ @Get(':id')
50
+ @ApiOperation({ summary: 'Get item by id', description: 'Returns a single item' })
51
+ @ApiResponse({ status: 200, description: 'Item found successfully', type: Item })
52
+ @ApiHeader({ name: 'x-request-id', description: 'Correlation identifier' })
53
+ getItem(@Param('id') id: string, @Query('fields') fields?: string) {
54
+ return { id: 1, name: 'Gadget' };
55
+ }
56
+
57
+ @Post()
58
+ @ApiOperation({ summary: 'Create a new item' })
59
+ @ApiResponse({ status: 201, description: 'Item created successfully', type: Item })
60
+ createItem(@Body() body: CreateItemDto) {
61
+ return { id: 2, ...body };
62
+ }
63
+
64
+ @Post('update')
65
+ @ApiOperation({ summary: 'Partial update' })
66
+ updateItem(@Body() body: UpdateItemDto) {
67
+ return { id: 3, ...body };
68
+ }
69
+ }
70
+
71
+ @Module({
72
+ controllers: [ItemsController],
73
+ })
74
+ class TestApp {}
75
+
76
+ describe('OpenAPI (Swagger) Generation', () => {
77
+ let app: any;
78
+ let baseUrl: string;
79
+ const PORT = 3932;
80
+
81
+ beforeAll(async () => {
82
+ app = await CalyxFactory.create(TestApp);
83
+
84
+ // Build OpenAPI config with bearer authentication
85
+ const config = new DocumentBuilder()
86
+ .setTitle('My Test API')
87
+ .setDescription('OpenAPI description')
88
+ .setVersion('2.0.0')
89
+ .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
90
+ .build();
91
+
92
+ const document = SwaggerModule.createDocument(app, config);
93
+ SwaggerModule.setup('api', app, document);
94
+
95
+ await app.listen(PORT);
96
+ baseUrl = `http://localhost:${PORT}`;
97
+ });
98
+
99
+ afterAll(async () => {
100
+ await app.close();
101
+ });
102
+
103
+ test('should serve OpenAPI JSON specification with correct types and schemas', async () => {
104
+ const res = await fetch(`${baseUrl}/api-json`);
105
+ expect(res.status).toBe(200);
106
+ const spec = await res.json();
107
+
108
+ // 1. Basic Specs
109
+ expect(spec.openapi).toBe('3.0.0');
110
+ expect(spec.info.title).toBe('My Test API');
111
+ expect(spec.info.version).toBe('2.0.0');
112
+
113
+ // 2. Security Setup
114
+ expect(spec.components.securitySchemes.jwt).toBeDefined();
115
+ expect(spec.components.securitySchemes.jwt.scheme).toBe('bearer');
116
+
117
+ // 3. GET /items/{id} endpoint parameters (path, query, header)
118
+ expect(spec.paths['/items/{id}']).toBeDefined();
119
+ const getOp = spec.paths['/items/{id}'].get;
120
+ expect(getOp.summary).toBe('Get item by id');
121
+ expect(getOp.tags).toContain('Items');
122
+ expect(getOp.security).toEqual([{ jwt: [] }]);
123
+
124
+ const params = getOp.parameters;
125
+ const pathP = params.find((p: any) => p.in === 'path');
126
+ const queryP = params.find((p: any) => p.in === 'query');
127
+ const headerP = params.find((p: any) => p.in === 'header');
128
+
129
+ expect(pathP).toBeDefined();
130
+ expect(pathP.name).toBe('id');
131
+
132
+ expect(queryP).toBeDefined();
133
+ expect(queryP.name).toBe('fields');
134
+
135
+ expect(headerP).toBeDefined();
136
+ expect(headerP.name).toBe('x-request-id');
137
+
138
+ // 4. POST /items request body and schema DTO definition
139
+ expect(spec.paths['/items']).toBeDefined();
140
+ const postOp = spec.paths['/items'].post;
141
+ expect(postOp.requestBody).toBeDefined();
142
+ expect(postOp.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/CreateItemDto');
143
+
144
+ expect(spec.components.schemas.CreateItemDto).toBeDefined();
145
+ expect(spec.components.schemas.CreateItemDto.required).toContain('name');
146
+ expect(spec.components.schemas.CreateItemDto.properties.name.type).toBe('string');
147
+ expect(spec.components.schemas.CreateItemDto.properties.price.type).toBe('number');
148
+
149
+ // 5. Mapped Type (PartialType)
150
+ expect(spec.components.schemas.UpdateItemDto).toBeDefined();
151
+ expect(spec.components.schemas.UpdateItemDto.required).toBeUndefined(); // all optional
152
+ expect(spec.components.schemas.UpdateItemDto.properties.name.type).toBe('string');
153
+ });
154
+
155
+ test('should serve Swagger UI html wrapper', async () => {
156
+ const res = await fetch(`${baseUrl}/api`);
157
+ expect(res.status).toBe(200);
158
+ const html = await res.text();
159
+ expect(html).toContain('swagger-ui');
160
+ expect(html).toContain('window.ui = SwaggerUIBundle');
161
+ });
162
+ });