@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
@@ -1,17 +1,30 @@
1
1
  import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
- import { Module, CalyxFactory } from '../src/index.ts';
2
+ import {
3
+ Module,
4
+ CalyxFactory,
5
+ UseGuards,
6
+ UseInterceptors,
7
+ CanActivate,
8
+ NestInterceptor,
9
+ CallHandler,
10
+ ExecutionContext,
11
+ } from '../src/index.ts';
3
12
  import {
4
13
  Resolver,
5
14
  Query,
15
+ Mutation,
16
+ Subscription,
6
17
  ResolveField,
7
18
  Args,
8
19
  Parent,
9
20
  ObjectType,
21
+ InputType,
22
+ ArgsType,
10
23
  Field,
11
24
  GraphQLModule,
12
25
  } from '../src/graphql/index.ts';
13
26
 
14
- // 1. GraphQL Object Type DTO
27
+ // 1. DTOs
15
28
  @ObjectType()
16
29
  class Author {
17
30
  @Field()
@@ -33,15 +46,96 @@ class PostGql {
33
46
  author!: Author;
34
47
  }
35
48
 
36
- // 2. Resolver Class
49
+ @InputType()
50
+ class CreatePostInput {
51
+ @Field()
52
+ title!: string;
53
+
54
+ @Field()
55
+ authorId!: number;
56
+ }
57
+
58
+ @ArgsType()
59
+ class GetPostArgs {
60
+ @Field()
61
+ id!: number;
62
+ }
63
+
64
+ // 2. Enhancers (Guard & Interceptor)
65
+ class GqlGuard implements CanActivate {
66
+ canActivate(context: ExecutionContext): boolean {
67
+ const gqlContext = context.getArgByIndex(2);
68
+ const req = gqlContext?.req;
69
+ const auth = req?.headers?.get('Authorization');
70
+ return auth === 'allow';
71
+ }
72
+ }
73
+
74
+ class GqlInterceptor implements NestInterceptor {
75
+ async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
76
+ const res = await next.handle();
77
+ if (res && typeof res === 'object') {
78
+ return { ...res, title: res.title + ' [intercepted]' };
79
+ }
80
+ return res;
81
+ }
82
+ }
83
+
84
+ // 3. Resolver Class
37
85
  @Resolver(PostGql)
38
86
  class PostResolver {
39
87
  @Query(() => PostGql)
40
- getPost(@Args('id') id: number) {
88
+ getPost(@Args() args: GetPostArgs) {
41
89
  return {
42
- id,
90
+ id: args.id,
43
91
  title: `Calyx: GraphQL JIT Performance`,
44
- authorId: 456, // to be resolved by ResolveField
92
+ authorId: 456,
93
+ };
94
+ }
95
+
96
+ @Query(() => PostGql)
97
+ @UseGuards(GqlGuard)
98
+ @UseInterceptors(GqlInterceptor)
99
+ getPostSecured(@Args() args: GetPostArgs) {
100
+ return {
101
+ id: args.id,
102
+ title: `Calyx Secure`,
103
+ authorId: 111,
104
+ };
105
+ }
106
+
107
+ @Mutation(() => PostGql)
108
+ createPost(@Args('input') input: CreatePostInput) {
109
+ return {
110
+ id: 999,
111
+ title: input.title,
112
+ authorId: input.authorId,
113
+ };
114
+ }
115
+
116
+ @Subscription(() => PostGql)
117
+ postAdded() {
118
+ return {
119
+ [Symbol.asyncIterator]() {
120
+ let index = 0;
121
+ return {
122
+ async next() {
123
+ if (index < 1) {
124
+ index++;
125
+ return {
126
+ value: {
127
+ postAdded: {
128
+ id: 777,
129
+ title: 'New Post Added',
130
+ },
131
+ },
132
+ done: false,
133
+ };
134
+ }
135
+ return { value: undefined, done: true };
136
+ },
137
+ };
138
+ },
45
139
  };
46
140
  }
47
141
 
@@ -109,4 +203,149 @@ describe('Native Code-First GraphQL Module', () => {
109
203
  },
110
204
  });
111
205
  });
206
+
207
+ test('should execute Mutation with InputType successfully', async () => {
208
+ const res = await fetch(`${baseUrl}/graphql`, {
209
+ method: 'POST',
210
+ headers: { 'content-type': 'application/json' },
211
+ body: JSON.stringify({
212
+ query: `
213
+ mutation {
214
+ createPost(input: { title: "Calyx Rocks", authorId: 789 }) {
215
+ id
216
+ title
217
+ author {
218
+ id
219
+ name
220
+ }
221
+ }
222
+ }
223
+ `,
224
+ }),
225
+ });
226
+
227
+ expect(res.status).toBe(200);
228
+ const body = await res.json();
229
+ expect(body.errors).toBeUndefined();
230
+ expect(body.data).toEqual({
231
+ createPost: {
232
+ id: 999,
233
+ title: 'Calyx Rocks',
234
+ author: {
235
+ id: 789,
236
+ name: 'Jane Doe',
237
+ },
238
+ },
239
+ });
240
+ });
241
+
242
+ test('should run Guards and deny access if guard denies', async () => {
243
+ const res = await fetch(`${baseUrl}/graphql`, {
244
+ method: 'POST',
245
+ headers: { 'content-type': 'application/json' },
246
+ body: JSON.stringify({
247
+ query: `
248
+ query {
249
+ getPostSecured(id: 456) {
250
+ id
251
+ title
252
+ }
253
+ }
254
+ `,
255
+ }),
256
+ });
257
+
258
+ expect(res.status).toBe(200);
259
+ const body = await res.json();
260
+ expect(body.errors).toBeDefined();
261
+ expect(body.errors[0].message).toContain('Forbidden resource');
262
+ });
263
+
264
+ test('should run Guards and Interceptors successfully if guard approves', async () => {
265
+ const res = await fetch(`${baseUrl}/graphql`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'content-type': 'application/json',
269
+ 'Authorization': 'allow',
270
+ },
271
+ body: JSON.stringify({
272
+ query: `
273
+ query {
274
+ getPostSecured(id: 456) {
275
+ id
276
+ title
277
+ }
278
+ }
279
+ `,
280
+ }),
281
+ });
282
+
283
+ expect(res.status).toBe(200);
284
+ const body = await res.json();
285
+ expect(body.errors).toBeUndefined();
286
+ expect(body.data.getPostSecured).toEqual({
287
+ id: 456,
288
+ title: 'Calyx Secure [intercepted]',
289
+ });
290
+ });
291
+
292
+ test('should execute subscription over WebSocket using graphql-ws protocol', async () => {
293
+ const ws = new WebSocket(`ws://localhost:${PORT}/graphql`);
294
+ const messages: any[] = [];
295
+ ws.onmessage = (event) => {
296
+ messages.push(JSON.parse(event.data));
297
+ };
298
+
299
+ await new Promise((resolve) => {
300
+ ws.onopen = resolve;
301
+ });
302
+
303
+ // Send connection_init
304
+ ws.send(JSON.stringify({ type: 'connection_init' }));
305
+
306
+ // Wait for connection_ack
307
+ await new Promise((resolve) => {
308
+ const check = setInterval(() => {
309
+ if (messages.some((m) => m.type === 'connection_ack')) {
310
+ clearInterval(check);
311
+ resolve(null);
312
+ }
313
+ }, 5);
314
+ });
315
+
316
+ // Send subscribe
317
+ ws.send(JSON.stringify({
318
+ type: 'subscribe',
319
+ id: '1',
320
+ payload: {
321
+ query: `
322
+ subscription {
323
+ postAdded {
324
+ id
325
+ title
326
+ }
327
+ }
328
+ `,
329
+ },
330
+ }));
331
+
332
+ // Wait for next message
333
+ await new Promise((resolve) => {
334
+ const check = setInterval(() => {
335
+ if (messages.some((m) => m.type === 'next' && m.id === '1')) {
336
+ clearInterval(check);
337
+ resolve(null);
338
+ }
339
+ }, 5);
340
+ });
341
+
342
+ const nextMsg = messages.find((m) => m.type === 'next' && m.id === '1');
343
+ expect(nextMsg).toBeDefined();
344
+ expect(nextMsg.payload.data.postAdded).toEqual({
345
+ id: 777,
346
+ title: 'New Post Added',
347
+ });
348
+
349
+ ws.close();
350
+ });
112
351
  });
@@ -3,17 +3,25 @@ import {
3
3
  Module,
4
4
  Controller,
5
5
  Get,
6
+ Post,
6
7
  Param,
8
+ Query,
9
+ Body,
7
10
  CalyxFactory,
8
11
  ApiTags,
9
12
  ApiOperation,
10
13
  ApiResponse,
11
14
  ApiProperty,
15
+ ApiBody,
16
+ ApiQuery,
17
+ ApiHeader,
18
+ ApiBearerAuth,
12
19
  DocumentBuilder,
13
20
  SwaggerModule,
21
+ PartialType,
14
22
  } from '../src/index.ts';
15
23
 
16
- // 1. Model class
24
+ // 1. DTO Model
17
25
  class Item {
18
26
  @ApiProperty({ description: 'The unique identifier', type: Number })
19
27
  id!: number;
@@ -22,15 +30,42 @@ class Item {
22
30
  name!: string;
23
31
  }
24
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
+
25
45
  @ApiTags('Items')
46
+ @ApiBearerAuth('jwt')
26
47
  @Controller('items')
27
48
  class ItemsController {
28
49
  @Get(':id')
29
50
  @ApiOperation({ summary: 'Get item by id', description: 'Returns a single item' })
30
51
  @ApiResponse({ status: 200, description: 'Item found successfully', type: Item })
31
- getItem(@Param('id') id: string) {
52
+ @ApiHeader({ name: 'x-request-id', description: 'Correlation identifier' })
53
+ getItem(@Param('id') id: string, @Query('fields') fields?: string) {
32
54
  return { id: 1, name: 'Gadget' };
33
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
+ }
34
69
  }
35
70
 
36
71
  @Module({
@@ -46,11 +81,12 @@ describe('OpenAPI (Swagger) Generation', () => {
46
81
  beforeAll(async () => {
47
82
  app = await CalyxFactory.create(TestApp);
48
83
 
49
- // Build OpenAPI config
84
+ // Build OpenAPI config with bearer authentication
50
85
  const config = new DocumentBuilder()
51
86
  .setTitle('My Test API')
52
87
  .setDescription('OpenAPI description')
53
88
  .setVersion('2.0.0')
89
+ .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
54
90
  .build();
55
91
 
56
92
  const document = SwaggerModule.createDocument(app, config);
@@ -64,25 +100,56 @@ describe('OpenAPI (Swagger) Generation', () => {
64
100
  await app.close();
65
101
  });
66
102
 
67
- test('should serve OpenAPI JSON specification with paths and components', async () => {
103
+ test('should serve OpenAPI JSON specification with correct types and schemas', async () => {
68
104
  const res = await fetch(`${baseUrl}/api-json`);
69
105
  expect(res.status).toBe(200);
70
106
  const spec = await res.json();
71
107
 
108
+ // 1. Basic Specs
72
109
  expect(spec.openapi).toBe('3.0.0');
73
110
  expect(spec.info.title).toBe('My Test API');
74
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)
75
118
  expect(spec.paths['/items/{id}']).toBeDefined();
76
-
77
119
  const getOp = spec.paths['/items/{id}'].get;
78
120
  expect(getOp.summary).toBe('Get item by id');
79
121
  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');
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');
86
153
  });
87
154
 
88
155
  test('should serve Swagger UI html wrapper', async () => {