@martel/calyx 1.10.1 → 1.12.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.
@@ -0,0 +1,47 @@
1
+ import { Module, DynamicModule } from '../core/decorators.ts';
2
+ import { ClientTcp } from './client-tcp.ts';
3
+
4
+ export function Client(options: any): PropertyDecorator {
5
+ return (target: any, propertyKey: string | symbol) => {
6
+ let clientInstance: any = null;
7
+ Object.defineProperty(target, propertyKey, {
8
+ get() {
9
+ if (!clientInstance) {
10
+ clientInstance = new ClientTcp(options?.options || options || {});
11
+ }
12
+ return clientInstance;
13
+ },
14
+ configurable: true,
15
+ enumerable: true,
16
+ });
17
+ };
18
+ }
19
+
20
+ export interface ClientProviderConfig {
21
+ name: string;
22
+ transport?: any;
23
+ options?: {
24
+ host?: string;
25
+ port?: number;
26
+ };
27
+ }
28
+
29
+ @Module({})
30
+ export class ClientsModule {
31
+ static register(clients: ClientProviderConfig[]): DynamicModule {
32
+ const providers = clients.map((client) => {
33
+ return {
34
+ provide: client.name,
35
+ useFactory: () => {
36
+ return new ClientTcp(client.options || {});
37
+ },
38
+ };
39
+ });
40
+
41
+ return {
42
+ module: ClientsModule,
43
+ providers,
44
+ exports: clients.map((c) => c.name),
45
+ };
46
+ }
47
+ }
@@ -4,3 +4,4 @@ export * from './client-tcp.ts';
4
4
  export * from './decorators.ts';
5
5
  export * from './server-tcp.ts';
6
6
  export * from './microservice.ts';
7
+ export * from './clients.module.ts';
@@ -31,7 +31,7 @@ export class CalyxMicroservice {
31
31
  async listen(): Promise<any> {
32
32
  if (this.isListening) return;
33
33
 
34
- this.container.bootstrap(this.rootModule);
34
+ await this.container.bootstrap(this.rootModule);
35
35
  this.server.registerHandlers(this.container, this.globalGuards, this.globalInterceptors);
36
36
 
37
37
  const hostInfo = await this.server.listen();
@@ -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
 
@@ -1,28 +1,32 @@
1
1
  import 'reflect-metadata';
2
2
 
3
- export function Cron(expression: string): MethodDecorator {
3
+ export function Cron(expression: string, options?: { name?: string }): MethodDecorator {
4
4
  return (target, propertyKey) => {
5
5
  const constructor = target.constructor;
6
6
  const existing = Reflect.getOwnMetadata('calyx:cron', constructor) || [];
7
- existing.push({ expression, propertyKey });
7
+ existing.push({ expression, propertyKey, name: options?.name });
8
8
  Reflect.defineMetadata('calyx:cron', existing, constructor);
9
9
  };
10
10
  }
11
11
 
12
- export function Interval(ms: number): MethodDecorator {
12
+ export function Interval(nameOrMs: string | number, ms?: number): MethodDecorator {
13
13
  return (target, propertyKey) => {
14
14
  const constructor = target.constructor;
15
+ const name = typeof nameOrMs === 'string' ? nameOrMs : undefined;
16
+ const intervalMs = typeof nameOrMs === 'number' ? nameOrMs : ms!;
15
17
  const existing = Reflect.getOwnMetadata('calyx:interval', constructor) || [];
16
- existing.push({ ms, propertyKey });
18
+ existing.push({ ms: intervalMs, propertyKey, name });
17
19
  Reflect.defineMetadata('calyx:interval', existing, constructor);
18
20
  };
19
21
  }
20
22
 
21
- export function Timeout(ms: number): MethodDecorator {
23
+ export function Timeout(nameOrMs: string | number, ms?: number): MethodDecorator {
22
24
  return (target, propertyKey) => {
23
25
  const constructor = target.constructor;
26
+ const name = typeof nameOrMs === 'string' ? nameOrMs : undefined;
27
+ const timeoutMs = typeof nameOrMs === 'number' ? nameOrMs : ms!;
24
28
  const existing = Reflect.getOwnMetadata('calyx:timeout', constructor) || [];
25
- existing.push({ ms, propertyKey });
29
+ existing.push({ ms: timeoutMs, propertyKey, name });
26
30
  Reflect.defineMetadata('calyx:timeout', existing, constructor);
27
31
  };
28
32
  }
@@ -1,3 +1,4 @@
1
1
  export * from './decorators.ts';
2
2
  export * from './schedule.module.ts';
3
+ export * from './scheduler-registry.ts';
3
4
  export * from './cron.matcher.ts';
@@ -1,12 +1,13 @@
1
1
  import { Module, DynamicModule } from '../core/decorators.ts';
2
+ import { SchedulerRegistry } from './scheduler-registry.ts';
2
3
 
3
4
  @Module({})
4
5
  export class ScheduleModule {
5
6
  static forRoot(): DynamicModule {
6
7
  return {
7
8
  module: ScheduleModule,
8
- providers: [],
9
- exports: [],
9
+ providers: [SchedulerRegistry],
10
+ exports: [SchedulerRegistry],
10
11
  global: true,
11
12
  };
12
13
  }
@@ -0,0 +1,50 @@
1
+ import { Injectable } from '../core/decorators.ts';
2
+
3
+ @Injectable()
4
+ export class SchedulerRegistry {
5
+ private cronJobs = new Map<string, any>();
6
+ private intervals = new Map<string, any>();
7
+ private timeouts = new Map<string, any>();
8
+
9
+ // Cron
10
+ getCronJob(name: string) {
11
+ const job = this.cronJobs.get(name);
12
+ if (!job) throw new Error(`No Cron Job was found with the given name (${name})`);
13
+ return job;
14
+ }
15
+ getCronJobs(): Map<string, any> { return this.cronJobs; }
16
+ addCronJob(name: string, job: any) { this.cronJobs.set(name, job); }
17
+ deleteCronJob(name: string) {
18
+ const job = this.cronJobs.get(name);
19
+ if (job && typeof job.stop === 'function') job.stop();
20
+ this.cronJobs.delete(name);
21
+ }
22
+
23
+ // Interval
24
+ getInterval(name: string) {
25
+ const interval = this.intervals.get(name);
26
+ if (!interval) throw new Error(`No Interval was found with the given name (${name})`);
27
+ return interval;
28
+ }
29
+ getIntervals(): string[] { return Array.from(this.intervals.keys()); }
30
+ addInterval(name: string, intervalId: any) { this.intervals.set(name, intervalId); }
31
+ deleteInterval(name: string) {
32
+ const interval = this.intervals.get(name);
33
+ if (interval) clearInterval(interval);
34
+ this.intervals.delete(name);
35
+ }
36
+
37
+ // Timeout
38
+ getTimeout(name: string) {
39
+ const timeout = this.timeouts.get(name);
40
+ if (!timeout) throw new Error(`No Timeout was found with the given name (${name})`);
41
+ return timeout;
42
+ }
43
+ getTimeouts(): string[] { return Array.from(this.timeouts.keys()); }
44
+ addTimeout(name: string, timeoutId: any) { this.timeouts.set(name, timeoutId); }
45
+ deleteTimeout(name: string) {
46
+ const timeout = this.timeouts.get(name);
47
+ if (timeout) clearTimeout(timeout);
48
+ this.timeouts.delete(name);
49
+ }
50
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './hashing.service.ts';
2
2
  export * from './cors.middleware.ts';
3
3
  export * from './helmet.middleware.ts';
4
+ export * from './throttler.module.ts';
@@ -0,0 +1,108 @@
1
+ import { Module, DynamicModule, Injectable, Inject } from '../core/decorators.ts';
2
+ import { CanActivate, ExecutionContext } from '../lifecycle/interfaces.ts';
3
+ import { HttpException } from '../http/exceptions.ts';
4
+
5
+ export const THROTTLE_LIMIT_KEY = 'throttler:limit';
6
+ export const THROTTLE_SKIP_KEY = 'throttler:skip';
7
+
8
+ export const Throttle = (limit: number, ttl: number) => (target: any, key?: string | symbol, descriptor?: any) => {
9
+ if (descriptor) {
10
+ Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, descriptor.value);
11
+ return descriptor;
12
+ }
13
+ Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, target);
14
+ return target;
15
+ };
16
+
17
+ export const SkipThrottle = (skip = true) => (target: any, key?: string | symbol, descriptor?: any) => {
18
+ if (descriptor) {
19
+ Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, descriptor.value);
20
+ return descriptor;
21
+ }
22
+ Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, target);
23
+ return target;
24
+ };
25
+
26
+ export interface ThrottlerModuleOptions {
27
+ limit?: number;
28
+ ttl?: number;
29
+ }
30
+
31
+ @Injectable()
32
+ export class ThrottlerStorage {
33
+ private records = new Map<string, { count: number; expiresAt: number }>();
34
+
35
+ increment(key: string, ttlSeconds: number): { count: number; expiresAt: number } {
36
+ const now = Date.now();
37
+ const record = this.records.get(key);
38
+ if (!record || record.expiresAt < now) {
39
+ const newRecord = { count: 1, expiresAt: now + ttlSeconds * 1000 };
40
+ this.records.set(key, newRecord);
41
+ return newRecord;
42
+ }
43
+ record.count++;
44
+ return record;
45
+ }
46
+ }
47
+
48
+ @Injectable()
49
+ export class ThrottlerGuard implements CanActivate {
50
+ constructor(
51
+ @Inject('THROTTLER_OPTIONS')
52
+ private readonly options: ThrottlerModuleOptions,
53
+ private readonly storage: ThrottlerStorage
54
+ ) {}
55
+
56
+ async canActivate(context: ExecutionContext): Promise<boolean> {
57
+ const handler = context.getHandler();
58
+ const controller = context.getClass();
59
+
60
+ const skipHandler = Reflect.getMetadata(THROTTLE_SKIP_KEY, handler);
61
+ if (skipHandler === true) return true;
62
+ const skipController = Reflect.getMetadata(THROTTLE_SKIP_KEY, controller);
63
+ if (skipController === true) return true;
64
+
65
+ const limitMeta = Reflect.getMetadata(THROTTLE_LIMIT_KEY, handler) ||
66
+ Reflect.getMetadata(THROTTLE_LIMIT_KEY, controller) ||
67
+ this.options;
68
+
69
+ const limit = limitMeta.limit ?? 10;
70
+ const ttl = limitMeta.ttl ?? 60;
71
+
72
+ const type = context.getType();
73
+ if (type !== 'http') return true;
74
+
75
+ const req = context.switchToHttp().getRequest<Request>();
76
+ const ip = req.headers.get('x-forwarded-for') || '';
77
+ const url = new URL(req.url);
78
+ const key = `throttle::${ip}::${url.pathname}`;
79
+
80
+ const record = this.storage.increment(key, ttl);
81
+ if (record.count > limit) {
82
+ throw new HttpException({
83
+ statusCode: 429,
84
+ message: 'ThrottlerException: Too Many Requests',
85
+ }, 429);
86
+ }
87
+
88
+ return true;
89
+ }
90
+ }
91
+
92
+ @Module({})
93
+ export class ThrottlerModule {
94
+ static register(options: ThrottlerModuleOptions = {}): DynamicModule {
95
+ return {
96
+ module: ThrottlerModule,
97
+ providers: [
98
+ {
99
+ provide: 'THROTTLER_OPTIONS',
100
+ useValue: options,
101
+ },
102
+ ThrottlerStorage,
103
+ ThrottlerGuard,
104
+ ],
105
+ exports: [ThrottlerStorage, ThrottlerGuard],
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,61 @@
1
+ import { Injectable, Module, SetMetadata } from '../core/decorators.ts';
2
+ import { HttpException } from '../http/exceptions.ts';
3
+
4
+ export const HEALTH_CHECK_KEY = 'terminus:healthcheck';
5
+ export const HealthCheck = () => SetMetadata(HEALTH_CHECK_KEY, true);
6
+
7
+ @Injectable()
8
+ export class HealthCheckService {
9
+ async check(indicators: (() => Promise<any> | any)[]): Promise<any> {
10
+ const info: Record<string, any> = {};
11
+ const error: Record<string, any> = {};
12
+ let isHealthy = true;
13
+
14
+ for (const indicator of indicators) {
15
+ try {
16
+ const result = await indicator();
17
+ Object.assign(info, result);
18
+ } catch (err: any) {
19
+ isHealthy = false;
20
+ Object.assign(error, err.payload || { [err.message || 'unknown']: { status: 'down' } });
21
+ }
22
+ }
23
+
24
+ const status = isHealthy ? 'ok' : 'error';
25
+ const responsePayload = {
26
+ status,
27
+ info: isHealthy ? info : {},
28
+ error: !isHealthy ? error : {},
29
+ details: { ...info, ...error },
30
+ };
31
+
32
+ if (!isHealthy) {
33
+ throw new HttpException(responsePayload, 503);
34
+ }
35
+ return responsePayload;
36
+ }
37
+ }
38
+
39
+ @Injectable()
40
+ export class HttpHealthIndicator {
41
+ async pingCheck(key: string, url: string): Promise<any> {
42
+ try {
43
+ const res = await fetch(url);
44
+ if (!res.ok) {
45
+ throw new Error(`Response status code ${res.status}`);
46
+ }
47
+ return { [key]: { status: 'up' } };
48
+ } catch (err: any) {
49
+ const payload = { [key]: { status: 'down', message: err.message } };
50
+ const error: any = new Error(`Health check failed for URL: ${url}`);
51
+ error.payload = payload;
52
+ throw error;
53
+ }
54
+ }
55
+ }
56
+
57
+ @Module({
58
+ providers: [HealthCheckService, HttpHealthIndicator],
59
+ exports: [HealthCheckService, HttpHealthIndicator],
60
+ })
61
+ export class TerminusModule {}
@@ -0,0 +1,128 @@
1
+ import { PipeTransform, ArgumentMetadata } from '../lifecycle/interfaces.ts';
2
+ import { Injectable } from '../core/decorators.ts';
3
+ import { HttpException } from '../http/exceptions.ts';
4
+
5
+ @Injectable()
6
+ export class ParseIntPipe implements PipeTransform<string | number, number> {
7
+ transform(value: string | number, metadata: ArgumentMetadata): number {
8
+ const val = typeof value === 'string' ? parseInt(value, 10) : value;
9
+ if (isNaN(val)) {
10
+ throw new HttpException(`Validation failed (numeric string is expected) for parameter "${metadata.data ?? ''}"`, 400);
11
+ }
12
+ return val;
13
+ }
14
+ }
15
+
16
+ @Injectable()
17
+ export class ParseFloatPipe implements PipeTransform<string | number, number> {
18
+ transform(value: string | number, metadata: ArgumentMetadata): number {
19
+ const val = typeof value === 'string' ? parseFloat(value) : value;
20
+ if (isNaN(val)) {
21
+ throw new HttpException(`Validation failed (float string is expected) for parameter "${metadata.data ?? ''}"`, 400);
22
+ }
23
+ return val;
24
+ }
25
+ }
26
+
27
+ @Injectable()
28
+ export class ParseBoolPipe implements PipeTransform<string | boolean, boolean> {
29
+ transform(value: string | boolean, metadata: ArgumentMetadata): boolean {
30
+ if (value === true || value === 'true') return true;
31
+ if (value === false || value === 'false') return false;
32
+ throw new HttpException(`Validation failed (boolean string is expected) for parameter "${metadata.data ?? ''}"`, 400);
33
+ }
34
+ }
35
+
36
+ export interface ParseArrayOptions {
37
+ items?: any;
38
+ separator?: string;
39
+ optional?: boolean;
40
+ }
41
+
42
+ @Injectable()
43
+ export class ParseArrayPipe implements PipeTransform {
44
+ constructor(private readonly options: ParseArrayOptions = {}) {}
45
+
46
+ transform(value: any, metadata: ArgumentMetadata): any[] {
47
+ if (value === undefined || value === null) {
48
+ if (this.options.optional) return [];
49
+ throw new HttpException(`Validation failed (array is expected) for parameter "${metadata.data ?? ''}"`, 400);
50
+ }
51
+ const separator = this.options.separator ?? ',';
52
+ let arr: any[] = [];
53
+ if (typeof value === 'string') {
54
+ arr = value.split(separator);
55
+ } else if (Array.isArray(value)) {
56
+ arr = value;
57
+ } else {
58
+ throw new HttpException(`Validation failed (array is expected) for parameter "${metadata.data ?? ''}"`, 400);
59
+ }
60
+
61
+ if (this.options.items) {
62
+ const type = this.options.items;
63
+ arr = arr.map((item) => {
64
+ if (type === Number) {
65
+ const val = Number(item);
66
+ if (isNaN(val)) throw new HttpException(`Validation failed (array of numbers is expected) for parameter "${metadata.data ?? ''}"`, 400);
67
+ return val;
68
+ }
69
+ if (type === Boolean) {
70
+ if (item === true || item === 'true') return true;
71
+ if (item === false || item === 'false') return false;
72
+ throw new HttpException(`Validation failed (array of booleans is expected) for parameter "${metadata.data ?? ''}"`, 400);
73
+ }
74
+ return item;
75
+ });
76
+ }
77
+ return arr;
78
+ }
79
+ }
80
+
81
+ @Injectable()
82
+ export class ParseUUIDPipe implements PipeTransform<string, string> {
83
+ private static readonly uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
84
+
85
+ transform(value: string, metadata: ArgumentMetadata): string {
86
+ if (typeof value !== 'string' || !ParseUUIDPipe.uuidRegex.test(value)) {
87
+ throw new HttpException(`Validation failed (uuid string is expected) for parameter "${metadata.data ?? ''}"`, 400);
88
+ }
89
+ return value;
90
+ }
91
+ }
92
+
93
+ @Injectable()
94
+ export class ParseEnumPipe implements PipeTransform {
95
+ constructor(private readonly enumType: any) {}
96
+
97
+ transform(value: any, metadata: ArgumentMetadata): any {
98
+ const values = Object.values(this.enumType);
99
+ if (!values.includes(value)) {
100
+ throw new HttpException(`Validation failed (enum value is expected) for parameter "${metadata.data ?? ''}"`, 400);
101
+ }
102
+ return value;
103
+ }
104
+ }
105
+
106
+ @Injectable()
107
+ export class ParseFilePipe implements PipeTransform {
108
+ constructor(private readonly validators: any[] = []) {}
109
+
110
+ transform(value: any, metadata: ArgumentMetadata): any {
111
+ if (!value || (typeof value === 'object' && !value.buffer)) {
112
+ throw new HttpException(`Validation failed (file is expected) for parameter "${metadata.data ?? ''}"`, 400);
113
+ }
114
+ return value;
115
+ }
116
+ }
117
+
118
+ @Injectable()
119
+ export class DefaultValuePipe implements PipeTransform {
120
+ constructor(private readonly defaultValue: any) {}
121
+
122
+ transform(value: any, metadata: ArgumentMetadata): any {
123
+ if (value === undefined || value === null || value === '') {
124
+ return this.defaultValue;
125
+ }
126
+ return value;
127
+ }
128
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './decorators.ts';
2
2
  export * from './compiler.ts';
3
3
  export * from './pipe.ts';
4
+ export * from './http-pipes.ts';
@@ -28,12 +28,22 @@ export function SubscribeMessage(event: string): MethodDecorator {
28
28
  };
29
29
  }
30
30
 
31
- export function MessageBody(): ParameterDecorator {
31
+ export function MessageBody(first?: any, ...pipes: any[]): ParameterDecorator {
32
32
  return (target, propertyKey, parameterIndex) => {
33
33
  if (!propertyKey) return;
34
34
  const constructor = target.constructor;
35
+
36
+ let name: string | undefined = undefined;
37
+ let parsedPipes: any[] = [];
38
+ if (typeof first === 'string') {
39
+ name = first;
40
+ parsedPipes = pipes;
41
+ } else if (first !== undefined) {
42
+ parsedPipes = [first, ...pipes];
43
+ }
44
+
35
45
  const existing = Reflect.getOwnMetadata('calyx:message_body', constructor) || [];
36
- existing.push({ propertyKey, parameterIndex });
46
+ existing.push({ propertyKey, parameterIndex, name, pipes: parsedPipes });
37
47
  Reflect.defineMetadata('calyx:message_body', existing, constructor);
38
48
  };
39
49
  }
@@ -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
+ });