@martel/calyx 1.10.0 → 1.11.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 +14 -0
- package/package.json +1 -1
- package/src/graphql/graphql.module.ts +87 -3
- package/src/http/application.ts +63 -7
- package/src/openapi/swagger.module.ts +32 -9
- package/tests/graphql.test.ts +101 -0
- package/tests/openapi.test.ts +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [1.11.0](https://github.com/bmartel/calyx/compare/v1.10.1...v1.11.0) (2026-07-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **graphql,openapi:** support schema-first compiling, custom context and validation schema auto-enrichment ([e8dcce7](https://github.com/bmartel/calyx/commit/e8dcce78d75097ac88a8a3a984b5834d4d7f1818))
|
|
7
|
+
|
|
8
|
+
## [1.10.1](https://github.com/bmartel/calyx/compare/v1.10.0...v1.10.1) (2026-07-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Performance Improvements
|
|
12
|
+
|
|
13
|
+
* **graphql,openapi:** implement query AST caching and swagger response caching ([8310045](https://github.com/bmartel/calyx/commit/8310045265512553214a0a4040bc02d5085c77fa))
|
|
14
|
+
|
|
1
15
|
# [1.10.0](https://github.com/bmartel/calyx/compare/v1.9.0...v1.10.0) (2026-07-01)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CalyxContainer } from '../core/container.ts';
|
|
1
|
+
import { CalyxContainer, DynamicModule } from '../core/container.ts';
|
|
2
2
|
import { Module } from '../core/decorators.ts';
|
|
3
3
|
import {
|
|
4
4
|
GraphQLSchema,
|
|
@@ -13,17 +13,42 @@ import {
|
|
|
13
13
|
GraphQLBoolean,
|
|
14
14
|
GraphQLList,
|
|
15
15
|
GraphQLNonNull,
|
|
16
|
+
buildSchema as buildGqlSchema,
|
|
16
17
|
} from 'graphql';
|
|
17
18
|
|
|
19
|
+
export interface GraphQLOptions {
|
|
20
|
+
typeDefs?: string;
|
|
21
|
+
context?: (ctx: { req: any }) => any | Promise<any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
@Module({})
|
|
19
25
|
export class GraphQLModule {
|
|
26
|
+
static forRoot(options: GraphQLOptions): DynamicModule {
|
|
27
|
+
return {
|
|
28
|
+
module: GraphQLModule,
|
|
29
|
+
providers: [
|
|
30
|
+
{
|
|
31
|
+
provide: 'calyx:graphql_options',
|
|
32
|
+
useValue: options,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
static buildSchema(container: CalyxContainer): GraphQLSchema | null {
|
|
21
39
|
const instances = container.getProviderAndControllerInstances();
|
|
22
40
|
const resolverInstances = instances.filter(
|
|
23
41
|
(inst) => inst && inst.constructor && Reflect.hasMetadata('calyx:resolver', inst.constructor)
|
|
24
42
|
);
|
|
25
43
|
|
|
26
|
-
|
|
44
|
+
let options: GraphQLOptions | undefined;
|
|
45
|
+
try {
|
|
46
|
+
options = container.getGlobalOrAnyInstance('calyx:graphql_options');
|
|
47
|
+
} catch {
|
|
48
|
+
// Options provider not bound, fallback to default code-first
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (resolverInstances.length === 0 && !options?.typeDefs) {
|
|
27
52
|
return null;
|
|
28
53
|
}
|
|
29
54
|
|
|
@@ -98,7 +123,12 @@ export class GraphQLModule {
|
|
|
98
123
|
// Custom Scalar
|
|
99
124
|
if (typeof typeClass === 'function' && Reflect.hasMetadata('calyx:scalar', typeClass)) {
|
|
100
125
|
const scalarMeta = Reflect.getMetadata('calyx:scalar', typeClass);
|
|
101
|
-
|
|
126
|
+
let inst: any;
|
|
127
|
+
try {
|
|
128
|
+
inst = container.getGlobalOrAnyInstance(typeClass);
|
|
129
|
+
} catch {
|
|
130
|
+
inst = new typeClass();
|
|
131
|
+
}
|
|
102
132
|
const gqlScalar = new GraphQLScalarType({
|
|
103
133
|
name: scalarMeta.name,
|
|
104
134
|
description: Reflect.getMetadata('calyx:description', typeClass),
|
|
@@ -480,6 +510,60 @@ export class GraphQLModule {
|
|
|
480
510
|
}
|
|
481
511
|
}
|
|
482
512
|
|
|
513
|
+
// Support Schema-First Approach if typeDefs is specified
|
|
514
|
+
if (options?.typeDefs) {
|
|
515
|
+
const schema = buildGqlSchema(options.typeDefs);
|
|
516
|
+
|
|
517
|
+
// Attach code-first resolver actions onto schema-first AST definitions
|
|
518
|
+
const queryType = schema.getQueryType();
|
|
519
|
+
if (queryType) {
|
|
520
|
+
const fields = queryType.getFields();
|
|
521
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
522
|
+
if (queryFields[fieldName]) {
|
|
523
|
+
field.resolve = queryFields[fieldName].resolve;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const mutationType = schema.getMutationType();
|
|
528
|
+
if (mutationType) {
|
|
529
|
+
const fields = mutationType.getFields();
|
|
530
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
531
|
+
if (mutationFields[fieldName]) {
|
|
532
|
+
field.resolve = mutationFields[fieldName].resolve;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const subscriptionType = schema.getSubscriptionType();
|
|
537
|
+
if (subscriptionType) {
|
|
538
|
+
const fields = subscriptionType.getFields();
|
|
539
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
540
|
+
if (subscriptionFields[fieldName]) {
|
|
541
|
+
field.subscribe = subscriptionFields[fieldName].subscribe;
|
|
542
|
+
field.resolve = subscriptionFields[fieldName].resolve;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Map Custom ObjectType ResolveFields from code-first typeMap
|
|
548
|
+
for (const [typeClass, codeFirstType] of typeMap.entries()) {
|
|
549
|
+
if (codeFirstType instanceof GraphQLObjectType) {
|
|
550
|
+
const typeName = codeFirstType.name;
|
|
551
|
+
const schemaFirstType = schema.getType(typeName);
|
|
552
|
+
if (schemaFirstType instanceof GraphQLObjectType) {
|
|
553
|
+
const codeFirstFields = codeFirstType.getFields();
|
|
554
|
+
const schemaFirstFields = schemaFirstType.getFields();
|
|
555
|
+
for (const [fieldName, cfField] of Object.entries(codeFirstFields)) {
|
|
556
|
+
if (cfField.resolve && schemaFirstFields[fieldName]) {
|
|
557
|
+
schemaFirstFields[fieldName].resolve = cfField.resolve;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return schema;
|
|
565
|
+
}
|
|
566
|
+
|
|
483
567
|
if (Object.keys(queryFields).length === 0) {
|
|
484
568
|
return null;
|
|
485
569
|
}
|
package/src/http/application.ts
CHANGED
|
@@ -119,6 +119,7 @@ export class CalyxApplication {
|
|
|
119
119
|
private hasWebSockets = false;
|
|
120
120
|
private serverPort = 3000;
|
|
121
121
|
private graphqlSchema: any = null;
|
|
122
|
+
private graphqlQueryCache = new Map<string, any>();
|
|
122
123
|
private isInitialized = false;
|
|
123
124
|
private versioningOptions?: VersioningOptions;
|
|
124
125
|
|
|
@@ -1215,11 +1216,40 @@ export class CalyxApplication {
|
|
|
1215
1216
|
const body = await req.json() as any;
|
|
1216
1217
|
const { query, variables } = body;
|
|
1217
1218
|
|
|
1218
|
-
const {
|
|
1219
|
-
|
|
1219
|
+
const { parse, validate, execute } = await import('graphql');
|
|
1220
|
+
|
|
1221
|
+
let document = this.graphqlQueryCache.get(query);
|
|
1222
|
+
if (!document) {
|
|
1223
|
+
document = parse(query);
|
|
1224
|
+
const errors = validate(this.graphqlSchema, document);
|
|
1225
|
+
if (errors.length > 0) {
|
|
1226
|
+
return new Response(JSON.stringify({ errors }), {
|
|
1227
|
+
status: 200,
|
|
1228
|
+
headers: { 'content-type': 'application/json' },
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
this.graphqlQueryCache.set(query, document);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
let options: any;
|
|
1235
|
+
try {
|
|
1236
|
+
options = this.container.get('calyx:graphql_options');
|
|
1237
|
+
} catch {}
|
|
1238
|
+
|
|
1239
|
+
let contextValue: any = { req };
|
|
1240
|
+
if (options?.context) {
|
|
1241
|
+
contextValue = await options.context({ req });
|
|
1242
|
+
if (contextValue && typeof contextValue === 'object') {
|
|
1243
|
+
contextValue.req = req;
|
|
1244
|
+
} else {
|
|
1245
|
+
contextValue = { req, ...contextValue };
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const result = await execute({
|
|
1220
1250
|
schema: this.graphqlSchema,
|
|
1221
|
-
|
|
1222
|
-
contextValue
|
|
1251
|
+
document,
|
|
1252
|
+
contextValue,
|
|
1223
1253
|
variableValues: variables,
|
|
1224
1254
|
});
|
|
1225
1255
|
|
|
@@ -1321,13 +1351,39 @@ export class CalyxApplication {
|
|
|
1321
1351
|
const { id, payload } = data;
|
|
1322
1352
|
const { query, variables } = payload;
|
|
1323
1353
|
|
|
1324
|
-
const { subscribe, parse } = await import('graphql');
|
|
1354
|
+
const { subscribe, parse, validate } = await import('graphql');
|
|
1325
1355
|
|
|
1356
|
+
let document = this.graphqlQueryCache.get(query);
|
|
1357
|
+
if (!document) {
|
|
1358
|
+
document = parse(query);
|
|
1359
|
+
const errors = validate(this.graphqlSchema, document);
|
|
1360
|
+
if (errors.length > 0) {
|
|
1361
|
+
ws.send(JSON.stringify({ type: 'error', id, payload: errors }));
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
this.graphqlQueryCache.set(query, document);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
let options: any;
|
|
1368
|
+
try {
|
|
1369
|
+
options = this.container.get('calyx:graphql_options');
|
|
1370
|
+
} catch {}
|
|
1371
|
+
|
|
1372
|
+
let contextValue: any = { req: ws.data?.req };
|
|
1373
|
+
if (options?.context) {
|
|
1374
|
+
contextValue = await options.context({ req: ws.data?.req });
|
|
1375
|
+
if (contextValue && typeof contextValue === 'object') {
|
|
1376
|
+
contextValue.req = ws.data?.req;
|
|
1377
|
+
} else {
|
|
1378
|
+
contextValue = { req: ws.data?.req, ...contextValue };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1326
1382
|
const subResult = await subscribe({
|
|
1327
1383
|
schema: this.graphqlSchema,
|
|
1328
|
-
document
|
|
1384
|
+
document,
|
|
1329
1385
|
variableValues: variables,
|
|
1330
|
-
contextValue
|
|
1386
|
+
contextValue,
|
|
1331
1387
|
});
|
|
1332
1388
|
|
|
1333
1389
|
if (subResult && Symbol.asyncIterator in subResult) {
|
|
@@ -73,20 +73,41 @@ export class SwaggerModule {
|
|
|
73
73
|
if (document.components.schemas[schemaName]) return;
|
|
74
74
|
|
|
75
75
|
const props = Reflect.getMetadata('calyx:api_properties', typeClass) || [];
|
|
76
|
+
const rules = Reflect.getMetadata('calyx:validation_rules', typeClass) || [];
|
|
76
77
|
const schemaProps: Record<string, any> = {};
|
|
77
78
|
const requiredProps: string[] = [];
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const allKeys = new Set<string>();
|
|
81
|
+
for (const p of props) allKeys.add(p.propertyKey);
|
|
82
|
+
for (const r of rules) allKeys.add(r.propertyKey);
|
|
83
|
+
|
|
84
|
+
for (const key of allKeys) {
|
|
85
|
+
const p = props.find((x: any) => x.propertyKey === key) || {};
|
|
86
|
+
const propertyRules = rules.filter((r: any) => r.propertyKey === key);
|
|
87
|
+
|
|
88
|
+
let pType = p.type ? (p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase()) : 'string';
|
|
89
|
+
let format: string | undefined = undefined;
|
|
90
|
+
|
|
91
|
+
if (propertyRules.some((r: any) => r.type === 'number')) {
|
|
92
|
+
pType = 'number';
|
|
93
|
+
} else if (propertyRules.some((r: any) => r.type === 'string')) {
|
|
94
|
+
pType = 'string';
|
|
95
|
+
} else if (propertyRules.some((r: any) => r.type === 'email')) {
|
|
96
|
+
pType = 'string';
|
|
97
|
+
format = 'email';
|
|
83
98
|
}
|
|
84
|
-
|
|
99
|
+
|
|
100
|
+
schemaProps[key] = {
|
|
85
101
|
type: pType === 'number' || pType === 'boolean' || pType === 'object' || pType === 'array' ? pType : 'string',
|
|
86
|
-
description: p.description,
|
|
102
|
+
description: p.description || '',
|
|
103
|
+
...(format ? { format } : {}),
|
|
87
104
|
};
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
|
|
106
|
+
const isOptional = propertyRules.some((r: any) => r.type === 'optional');
|
|
107
|
+
const isRequired = p.required ?? !isOptional;
|
|
108
|
+
|
|
109
|
+
if (isRequired) {
|
|
110
|
+
requiredProps.push(key);
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
|
|
@@ -271,12 +292,14 @@ export class SwaggerModule {
|
|
|
271
292
|
const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
|
|
272
293
|
const uiPath = `/${path}`.replace(/\/\/+/g, '/');
|
|
273
294
|
|
|
295
|
+
const jsonString = JSON.stringify(document);
|
|
296
|
+
|
|
274
297
|
app.use((req: any, res: any, next: any) => {
|
|
275
298
|
const url = new URL(req.url);
|
|
276
299
|
if (url.pathname === jsonPath && req.method === 'GET') {
|
|
277
300
|
res.status(200);
|
|
278
301
|
res.set('content-type', 'application/json');
|
|
279
|
-
res.send(
|
|
302
|
+
res.send(jsonString);
|
|
280
303
|
return;
|
|
281
304
|
}
|
|
282
305
|
next();
|
package/tests/graphql.test.ts
CHANGED
|
@@ -349,3 +349,104 @@ describe('Native Code-First GraphQL Module', () => {
|
|
|
349
349
|
ws.close();
|
|
350
350
|
});
|
|
351
351
|
});
|
|
352
|
+
|
|
353
|
+
describe('Schema-First GraphQL Module', () => {
|
|
354
|
+
let app: any;
|
|
355
|
+
let baseUrl: string;
|
|
356
|
+
const PORT = 3929;
|
|
357
|
+
|
|
358
|
+
const typeDefs = `
|
|
359
|
+
type Author {
|
|
360
|
+
id: Int!
|
|
361
|
+
name: String!
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type PostGql {
|
|
365
|
+
id: Int!
|
|
366
|
+
title: String!
|
|
367
|
+
author: Author!
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type Query {
|
|
371
|
+
getPost(id: Int!): PostGql
|
|
372
|
+
}
|
|
373
|
+
`;
|
|
374
|
+
|
|
375
|
+
@Resolver(PostGql)
|
|
376
|
+
class SchemaFirstResolver {
|
|
377
|
+
@Query()
|
|
378
|
+
getPost(@Args('id') id: number) {
|
|
379
|
+
return {
|
|
380
|
+
id,
|
|
381
|
+
title: 'Schema-First Works!',
|
|
382
|
+
authorId: 888,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@ResolveField()
|
|
387
|
+
author(@Parent() post: any) {
|
|
388
|
+
return {
|
|
389
|
+
id: post.authorId,
|
|
390
|
+
name: 'Bob',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
@Module({
|
|
396
|
+
imports: [
|
|
397
|
+
GraphQLModule.forRoot({
|
|
398
|
+
typeDefs,
|
|
399
|
+
context: async ({ req }) => {
|
|
400
|
+
return { customVal: 'injected' };
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
],
|
|
404
|
+
providers: [SchemaFirstResolver],
|
|
405
|
+
})
|
|
406
|
+
class SchemaFirstApp {}
|
|
407
|
+
|
|
408
|
+
beforeAll(async () => {
|
|
409
|
+
app = await CalyxFactory.create(SchemaFirstApp);
|
|
410
|
+
await app.listen(PORT);
|
|
411
|
+
baseUrl = `http://baseUrl:${PORT}`;
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
afterAll(async () => {
|
|
415
|
+
await app.close();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('should parse typeDefs and bind query/field resolver methods', async () => {
|
|
419
|
+
const res = await fetch(`http://localhost:${PORT}/graphql`, {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
headers: { 'content-type': 'application/json' },
|
|
422
|
+
body: JSON.stringify({
|
|
423
|
+
query: `
|
|
424
|
+
query {
|
|
425
|
+
getPost(id: 42) {
|
|
426
|
+
id
|
|
427
|
+
title
|
|
428
|
+
author {
|
|
429
|
+
id
|
|
430
|
+
name
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
`,
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(res.status).toBe(200);
|
|
439
|
+
const body = await res.json();
|
|
440
|
+
expect(body.errors).toBeUndefined();
|
|
441
|
+
expect(body.data).toEqual({
|
|
442
|
+
getPost: {
|
|
443
|
+
id: 42,
|
|
444
|
+
title: 'Schema-First Works!',
|
|
445
|
+
author: {
|
|
446
|
+
id: 888,
|
|
447
|
+
name: 'Bob',
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|
package/tests/openapi.test.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
DocumentBuilder,
|
|
20
20
|
SwaggerModule,
|
|
21
21
|
PartialType,
|
|
22
|
+
IsString,
|
|
23
|
+
IsNumber,
|
|
24
|
+
IsOptional,
|
|
25
|
+
IsEmail,
|
|
22
26
|
} from '../src/index.ts';
|
|
23
27
|
|
|
24
28
|
// 1. DTO Model
|
|
@@ -42,6 +46,22 @@ class CreateItemDto {
|
|
|
42
46
|
// 3. Partial DTO using mapped type
|
|
43
47
|
class UpdateItemDto extends PartialType(CreateItemDto) {}
|
|
44
48
|
|
|
49
|
+
// 4. Validation-rich DTO (without explicit @ApiProperty)
|
|
50
|
+
class ValidationRichDto {
|
|
51
|
+
@IsString()
|
|
52
|
+
title!: string;
|
|
53
|
+
|
|
54
|
+
@IsNumber()
|
|
55
|
+
amount!: number;
|
|
56
|
+
|
|
57
|
+
@IsOptional()
|
|
58
|
+
@IsString()
|
|
59
|
+
note?: string;
|
|
60
|
+
|
|
61
|
+
@IsEmail()
|
|
62
|
+
contactEmail!: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
@ApiTags('Items')
|
|
46
66
|
@ApiBearerAuth('jwt')
|
|
47
67
|
@Controller('items')
|
|
@@ -66,6 +86,12 @@ class ItemsController {
|
|
|
66
86
|
updateItem(@Body() body: UpdateItemDto) {
|
|
67
87
|
return { id: 3, ...body };
|
|
68
88
|
}
|
|
89
|
+
|
|
90
|
+
@Post('validate')
|
|
91
|
+
@ApiOperation({ summary: 'Validation endpoint' })
|
|
92
|
+
validateDto(@Body() body: ValidationRichDto) {
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
@Module({
|
|
@@ -150,6 +176,21 @@ describe('OpenAPI (Swagger) Generation', () => {
|
|
|
150
176
|
expect(spec.components.schemas.UpdateItemDto).toBeDefined();
|
|
151
177
|
expect(spec.components.schemas.UpdateItemDto.required).toBeUndefined(); // all optional
|
|
152
178
|
expect(spec.components.schemas.UpdateItemDto.properties.name.type).toBe('string');
|
|
179
|
+
|
|
180
|
+
// 6. Validation Decorator Schema Auto-enrichment
|
|
181
|
+
expect(spec.components.schemas.ValidationRichDto).toBeDefined();
|
|
182
|
+
const richSchema = spec.components.schemas.ValidationRichDto;
|
|
183
|
+
expect(richSchema.properties.title.type).toBe('string');
|
|
184
|
+
expect(richSchema.properties.amount.type).toBe('number');
|
|
185
|
+
expect(richSchema.properties.note.type).toBe('string');
|
|
186
|
+
expect(richSchema.properties.contactEmail.type).toBe('string');
|
|
187
|
+
expect(richSchema.properties.contactEmail.format).toBe('email');
|
|
188
|
+
|
|
189
|
+
// Ensure note is optional, but others are required
|
|
190
|
+
expect(richSchema.required).toContain('title');
|
|
191
|
+
expect(richSchema.required).toContain('amount');
|
|
192
|
+
expect(richSchema.required).toContain('contactEmail');
|
|
193
|
+
expect(richSchema.required).not.toContain('note');
|
|
153
194
|
});
|
|
154
195
|
|
|
155
196
|
test('should serve Swagger UI html wrapper', async () => {
|