@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.
- package/CHANGELOG.md +15 -0
- package/README.md +71 -27
- package/benchmarks/graphql-benchmark.ts +81 -0
- package/benchmarks/index.ts +32 -0
- package/benchmarks/openapi-benchmark.ts +168 -0
- package/benchmarks/serialization-benchmark.ts +52 -0
- package/benchmarks/techniques-benchmark.ts +84 -0
- package/benchmarks/validation-benchmark.ts +74 -0
- package/bun.lock +11 -0
- package/package.json +7 -6
- package/src/cli/index.ts +19 -3
- package/src/compression/compression.middleware.ts +7 -0
- package/src/cookies/cookies.ts +69 -0
- package/src/database/mongoose.module.ts +250 -0
- package/src/database/typeorm.module.ts +276 -0
- package/src/file-upload/file-upload.interceptor.ts +93 -0
- package/src/file-upload/index.ts +1 -0
- package/src/graphql/decorators.ts +70 -0
- package/src/graphql/graphql.module.ts +401 -57
- package/src/http/application.ts +434 -74
- package/src/http-client/http-client.module.ts +124 -0
- package/src/http-client/index.ts +1 -0
- package/src/index.ts +14 -0
- package/src/logger/index.ts +1 -0
- package/src/logger/logger.service.ts +118 -0
- package/src/mvc/index.ts +1 -0
- package/src/mvc/mvc.ts +22 -0
- package/src/openapi/decorators.ts +154 -0
- package/src/openapi/swagger.module.ts +172 -20
- package/src/queue/queue.module.ts +174 -0
- package/src/session/index.ts +1 -0
- package/src/session/session.middleware.ts +82 -0
- package/src/sse/index.ts +1 -0
- package/src/sse/sse.ts +18 -0
- package/src/streaming/index.ts +1 -0
- package/src/streaming/streamable-file.ts +32 -0
- package/src/validation/pipe.ts +79 -10
- package/src/versioning/versioning.ts +46 -0
- package/tests/graphql.test.ts +245 -6
- package/tests/openapi.test.ts +78 -11
- package/tests/techniques.test.ts +471 -0
package/tests/graphql.test.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
-
import {
|
|
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.
|
|
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
|
-
|
|
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(
|
|
88
|
+
getPost(@Args() args: GetPostArgs) {
|
|
41
89
|
return {
|
|
42
|
-
id,
|
|
90
|
+
id: args.id,
|
|
43
91
|
title: `Calyx: GraphQL JIT Performance`,
|
|
44
|
-
authorId: 456,
|
|
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
|
});
|
package/tests/openapi.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 () => {
|