@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -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
- if (resolverInstances.length === 0) {
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
- const inst = container.get(typeClass) || new typeClass();
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
  }
@@ -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: { req },
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: { req: ws.data?.req },
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
- for (const p of props) {
80
- let pType = 'string';
81
- if (p.type) {
82
- pType = p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase();
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
- schemaProps[p.propertyKey] = {
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
- if (p.required) {
89
- requiredProps.push(p.propertyKey);
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
 
@@ -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
+ });
@@ -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 () => {