@martel/calyx 1.10.1 → 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 +7 -0
- package/package.json +1 -1
- package/src/graphql/graphql.module.ts +87 -3
- package/src/http/application.ts +32 -2
- package/src/openapi/swagger.module.ts +29 -8
- package/tests/graphql.test.ts +101 -0
- package/tests/openapi.test.ts +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [1.10.1](https://github.com/bmartel/calyx/compare/v1.10.0...v1.10.1) (2026-07-01)
|
|
2
9
|
|
|
3
10
|
|
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
|
@@ -1231,10 +1231,25 @@ export class CalyxApplication {
|
|
|
1231
1231
|
this.graphqlQueryCache.set(query, document);
|
|
1232
1232
|
}
|
|
1233
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
|
+
|
|
1234
1249
|
const result = await execute({
|
|
1235
1250
|
schema: this.graphqlSchema,
|
|
1236
1251
|
document,
|
|
1237
|
-
contextValue
|
|
1252
|
+
contextValue,
|
|
1238
1253
|
variableValues: variables,
|
|
1239
1254
|
});
|
|
1240
1255
|
|
|
@@ -1349,11 +1364,26 @@ export class CalyxApplication {
|
|
|
1349
1364
|
this.graphqlQueryCache.set(query, document);
|
|
1350
1365
|
}
|
|
1351
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
|
+
|
|
1352
1382
|
const subResult = await subscribe({
|
|
1353
1383
|
schema: this.graphqlSchema,
|
|
1354
1384
|
document,
|
|
1355
1385
|
variableValues: variables,
|
|
1356
|
-
contextValue
|
|
1386
|
+
contextValue,
|
|
1357
1387
|
});
|
|
1358
1388
|
|
|
1359
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
|
|
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 () => {
|