@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.
- 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 +14 -0
- package/package.json +8 -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 +132 -0
- package/src/graphql/graphql.module.ts +316 -0
- package/src/graphql/index.ts +2 -0
- package/src/http/application.ts +380 -70
- package/src/http/factory.ts +1 -0
- package/src/http/router.ts +13 -0
- package/src/http-client/http-client.module.ts +124 -0
- package/src/http-client/index.ts +1 -0
- package/src/index.ts +15 -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 +203 -0
- package/src/openapi/index.ts +2 -0
- package/src/openapi/swagger.module.ts +326 -0
- 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 +176 -0
- package/tests/openapi.test.ts +162 -0
- package/tests/techniques.test.ts +471 -0
package/src/validation/pipe.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
+
});
|