@martel/calyx 1.6.0 → 1.7.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/cache/cache.interceptor.ts +32 -0
- package/src/cache/cache.module.ts +31 -0
- package/src/cache/cache.service.ts +86 -0
- package/src/cache/index.ts +3 -0
- package/src/http/application.ts +10 -0
- package/src/index.ts +2 -0
- package/src/validation/compiler.ts +124 -0
- package/src/validation/decorators.ts +47 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/pipe.ts +31 -0
- package/tests/cache.test.ts +93 -0
- package/tests/validation-serialization.test.ts +134 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.7.0](https://github.com/bmartel/calyx/compare/v1.6.0...v1.7.0) (2026-07-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **cache,validation:** implement SQLite CacheModule, JIT ValidationPipe, and JIT Response Serializer ([1d37b68](https://github.com/bmartel/calyx/commit/1d37b68c2a7ba52e7878810b63f75ee31becc433))
|
|
7
|
+
|
|
1
8
|
# [1.6.0](https://github.com/bmartel/calyx/compare/v1.5.0...v1.6.0) (2026-07-01)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NestInterceptor, ExecutionContext, CallHandler } from '../lifecycle/interfaces.ts';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
import { CacheService } from './cache.service.ts';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class CacheInterceptor implements NestInterceptor {
|
|
7
|
+
constructor(private readonly cacheService: CacheService) {}
|
|
8
|
+
|
|
9
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
|
|
10
|
+
const type = context.getType();
|
|
11
|
+
if (type !== 'http') {
|
|
12
|
+
return next.handle();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
16
|
+
if (req.method !== 'GET') {
|
|
17
|
+
return next.handle();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = new URL(req.url);
|
|
21
|
+
const key = `http_cache::${url.pathname}${url.search}`;
|
|
22
|
+
|
|
23
|
+
const cached = await this.cacheService.get(key);
|
|
24
|
+
if (cached !== undefined) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await next.handle();
|
|
29
|
+
await this.cacheService.set(key, result);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
2
|
+
import { CacheService } from './cache.service.ts';
|
|
3
|
+
import { CacheInterceptor } from './cache.interceptor.ts';
|
|
4
|
+
|
|
5
|
+
export interface CacheModuleOptions {
|
|
6
|
+
dbPath?: string;
|
|
7
|
+
defaultTtl?: number;
|
|
8
|
+
isGlobal?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Module({
|
|
12
|
+
providers: [CacheService, CacheInterceptor],
|
|
13
|
+
exports: [CacheService, CacheInterceptor],
|
|
14
|
+
})
|
|
15
|
+
export class CacheModule {
|
|
16
|
+
static register(options: CacheModuleOptions = {}): DynamicModule {
|
|
17
|
+
const cacheServiceInstance = new CacheService(options);
|
|
18
|
+
|
|
19
|
+
const cacheServiceProvider = {
|
|
20
|
+
provide: CacheService,
|
|
21
|
+
useValue: cacheServiceInstance,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
module: CacheModule,
|
|
26
|
+
providers: [cacheServiceProvider, CacheInterceptor],
|
|
27
|
+
exports: [CacheService, CacheInterceptor],
|
|
28
|
+
global: options.isGlobal ?? false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class CacheService {
|
|
6
|
+
private db!: Database;
|
|
7
|
+
private pruneTimer: any;
|
|
8
|
+
private readonly dbPath: string;
|
|
9
|
+
private readonly defaultTtl: number;
|
|
10
|
+
|
|
11
|
+
constructor(options: { dbPath?: string; defaultTtl?: number } = {}) {
|
|
12
|
+
this.dbPath = options.dbPath ?? ':memory:';
|
|
13
|
+
this.defaultTtl = options.defaultTtl ?? 5;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
onModuleInit() {
|
|
17
|
+
this.db = new Database(this.dbPath);
|
|
18
|
+
this.db.run(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT,
|
|
22
|
+
expires_at INTEGER
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_expires_at ON cache (expires_at)`);
|
|
26
|
+
|
|
27
|
+
this.pruneTimer = setInterval(() => this.prune(), 30000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onModuleDestroy() {
|
|
31
|
+
if (this.pruneTimer) {
|
|
32
|
+
clearInterval(this.pruneTimer);
|
|
33
|
+
}
|
|
34
|
+
if (this.db) {
|
|
35
|
+
this.db.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async get<T = any>(key: string): Promise<T | undefined> {
|
|
40
|
+
const row = this.db.query('SELECT value, expires_at FROM cache WHERE key = $key').get({ $key: key }) as any;
|
|
41
|
+
if (!row) return undefined;
|
|
42
|
+
|
|
43
|
+
if (row.expires_at !== null && row.expires_at < Date.now()) {
|
|
44
|
+
this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(row.value) as T;
|
|
50
|
+
} catch {
|
|
51
|
+
return row.value as unknown as T;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async set<T = any>(key: string, value: T, ttl?: number): Promise<void> {
|
|
56
|
+
const ttlSeconds = ttl !== undefined ? ttl : this.defaultTtl;
|
|
57
|
+
const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
|
|
58
|
+
const valStr = JSON.stringify(value);
|
|
59
|
+
|
|
60
|
+
this.db.query(`
|
|
61
|
+
INSERT INTO cache (key, value, expires_at)
|
|
62
|
+
VALUES ($key, $val, $expiresAt)
|
|
63
|
+
ON CONFLICT(key) DO UPDATE SET value = $val, expires_at = $expiresAt
|
|
64
|
+
`).run({
|
|
65
|
+
$key: key,
|
|
66
|
+
$val: valStr,
|
|
67
|
+
$expiresAt: expiresAt,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async del(key: string): Promise<void> {
|
|
72
|
+
this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async reset(): Promise<void> {
|
|
76
|
+
this.db.run('DELETE FROM cache');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private prune() {
|
|
80
|
+
try {
|
|
81
|
+
this.db.query('DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < $now').run({ $now: Date.now() });
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/http/application.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { EventEmitter } from '../event-emitter/event-emitter.ts';
|
|
|
10
10
|
import { cors, CorsOptions } from '../security/cors.middleware.ts';
|
|
11
11
|
import { helmet, HelmetOptions } from '../security/helmet.middleware.ts';
|
|
12
12
|
import { CronMatcher } from '../schedule/cron.matcher.ts';
|
|
13
|
+
import { SerializationCompiler } from '../validation/compiler.ts';
|
|
13
14
|
|
|
14
15
|
class ObjectPool<T> {
|
|
15
16
|
private pool: T[] = [];
|
|
@@ -888,6 +889,15 @@ export class CalyxApplication {
|
|
|
888
889
|
|
|
889
890
|
if (typeof result === 'object') {
|
|
890
891
|
responseHeaders['content-type'] = 'application/json';
|
|
892
|
+
const constructor = result.constructor;
|
|
893
|
+
if (constructor) {
|
|
894
|
+
const hasRules = Reflect.hasMetadata('calyx:validation_rules', constructor);
|
|
895
|
+
const hasExpose = Reflect.hasMetadata('calyx:expose_properties', constructor);
|
|
896
|
+
if (hasRules || hasExpose) {
|
|
897
|
+
const serialize = SerializationCompiler.compile(constructor);
|
|
898
|
+
return new Response(serialize(result), { status, headers: responseHeaders });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
891
901
|
return new Response(JSON.stringify(result), { status, headers: responseHeaders });
|
|
892
902
|
}
|
|
893
903
|
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { ValidationRule } from './decorators.ts';
|
|
3
|
+
|
|
4
|
+
export class ValidationCompiler {
|
|
5
|
+
private static readonly compiledValidators = new Map<any, (obj: any) => string[] | null>();
|
|
6
|
+
|
|
7
|
+
static compile(dtoClass: any): (obj: any) => string[] | null {
|
|
8
|
+
if (this.compiledValidators.has(dtoClass)) {
|
|
9
|
+
return this.compiledValidators.get(dtoClass)!;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const rules: ValidationRule[] = Reflect.getMetadata('calyx:validation_rules', dtoClass) || [];
|
|
13
|
+
|
|
14
|
+
const rulesByProp = new Map<string, ValidationRule[]>();
|
|
15
|
+
for (const rule of rules) {
|
|
16
|
+
let list = rulesByProp.get(rule.propertyKey);
|
|
17
|
+
if (!list) {
|
|
18
|
+
list = [];
|
|
19
|
+
rulesByProp.set(rule.propertyKey, list);
|
|
20
|
+
}
|
|
21
|
+
list.push(rule);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const codeParts: string[] = ['const errors = [];'];
|
|
25
|
+
|
|
26
|
+
for (const [prop, propRules] of rulesByProp.entries()) {
|
|
27
|
+
const isOptional = propRules.some((r) => r.type === 'optional');
|
|
28
|
+
|
|
29
|
+
const propCode: string[] = [];
|
|
30
|
+
for (const rule of propRules) {
|
|
31
|
+
if (rule.type === 'optional') continue;
|
|
32
|
+
|
|
33
|
+
if (rule.type === 'string') {
|
|
34
|
+
propCode.push(`if (typeof obj.${prop} !== 'string') errors.push('${prop} must be a string');`);
|
|
35
|
+
} else if (rule.type === 'number') {
|
|
36
|
+
propCode.push(`if (typeof obj.${prop} !== 'number' || isNaN(obj.${prop})) errors.push('${prop} must be a number');`);
|
|
37
|
+
} else if (rule.type === 'email') {
|
|
38
|
+
propCode.push(
|
|
39
|
+
`if (typeof obj.${prop} !== 'string' || !obj.${prop}.includes('@')) errors.push('${prop} must be a valid email');`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isOptional) {
|
|
45
|
+
codeParts.push(`if (obj.${prop} !== undefined && obj.${prop} !== null) {
|
|
46
|
+
${propCode.join('\n')}
|
|
47
|
+
}`);
|
|
48
|
+
} else {
|
|
49
|
+
codeParts.push(`if (obj.${prop} === undefined || obj.${prop} === null) {
|
|
50
|
+
errors.push('${prop} should not be empty');
|
|
51
|
+
} else {
|
|
52
|
+
${propCode.join('\n')}
|
|
53
|
+
}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
codeParts.push('return errors.length > 0 ? errors : null;');
|
|
58
|
+
|
|
59
|
+
const fnBody = codeParts.join('\n');
|
|
60
|
+
try {
|
|
61
|
+
const validator = new Function('obj', fnBody) as (obj: any) => string[] | null;
|
|
62
|
+
this.compiledValidators.set(dtoClass, validator);
|
|
63
|
+
return validator;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('Failed to compile validation JIT for class:', dtoClass.name || dtoClass);
|
|
66
|
+
console.error('Code body:', fnBody);
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class SerializationCompiler {
|
|
73
|
+
private static readonly compiledSerializers = new Map<any, (obj: any) => string>();
|
|
74
|
+
|
|
75
|
+
static compile(dtoClass: any): (obj: any) => string {
|
|
76
|
+
if (this.compiledSerializers.has(dtoClass)) {
|
|
77
|
+
return this.compiledSerializers.get(dtoClass)!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const excludes: Set<string> = Reflect.getMetadata('calyx:exclude_properties', dtoClass) || new Set();
|
|
81
|
+
|
|
82
|
+
const rules: ValidationRule[] = Reflect.getMetadata('calyx:validation_rules', dtoClass) || [];
|
|
83
|
+
const exposedKeys = new Set(rules.map((r) => r.propertyKey));
|
|
84
|
+
|
|
85
|
+
const exposes: Set<string> = Reflect.getMetadata('calyx:expose_properties', dtoClass) || new Set();
|
|
86
|
+
for (const exp of exposes) {
|
|
87
|
+
exposedKeys.add(exp);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const keys = Array.from(exposedKeys).filter((k) => !excludes.has(k));
|
|
91
|
+
|
|
92
|
+
const jsonParts: string[] = [];
|
|
93
|
+
for (const key of keys) {
|
|
94
|
+
const propRules = rules.filter((r) => r.propertyKey === key);
|
|
95
|
+
const isNumber = propRules.some((r) => r.type === 'number');
|
|
96
|
+
const isString = propRules.some((r) => r.type === 'string');
|
|
97
|
+
|
|
98
|
+
if (isNumber) {
|
|
99
|
+
jsonParts.push(`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : obj.${key}}`);
|
|
100
|
+
} else if (isString) {
|
|
101
|
+
jsonParts.push(
|
|
102
|
+
`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
jsonParts.push(
|
|
106
|
+
`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fnBody = `
|
|
112
|
+
return \`{${jsonParts.join(',')}}\`;
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const serializer = new Function('obj', fnBody) as (obj: any) => string;
|
|
117
|
+
this.compiledSerializers.set(dtoClass, serializer);
|
|
118
|
+
return serializer;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('Failed to compile JIT response serializer for class:', dtoClass.name || dtoClass);
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export interface ValidationRule {
|
|
4
|
+
type: string;
|
|
5
|
+
propertyKey: string;
|
|
6
|
+
args?: any[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function registerValidationRule(type: string, target: any, propertyKey: string, args?: any[]) {
|
|
10
|
+
const existing: ValidationRule[] = Reflect.getOwnMetadata('calyx:validation_rules', target.constructor) || [];
|
|
11
|
+
existing.push({ type, propertyKey, args });
|
|
12
|
+
Reflect.defineMetadata('calyx:validation_rules', existing, target.constructor);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function IsString(): PropertyDecorator {
|
|
16
|
+
return (target, propertyKey) => registerValidationRule('string', target, String(propertyKey));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function IsNumber(): PropertyDecorator {
|
|
20
|
+
return (target, propertyKey) => registerValidationRule('number', target, String(propertyKey));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function IsOptional(): PropertyDecorator {
|
|
24
|
+
return (target, propertyKey) => registerValidationRule('optional', target, String(propertyKey));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function IsEmail(): PropertyDecorator {
|
|
28
|
+
return (target, propertyKey) => registerValidationRule('email', target, String(propertyKey));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Expose(): PropertyDecorator {
|
|
32
|
+
return (target, propertyKey) => {
|
|
33
|
+
const constructor = target.constructor;
|
|
34
|
+
const existing = Reflect.getOwnMetadata('calyx:expose_properties', constructor) || new Set();
|
|
35
|
+
existing.add(String(propertyKey));
|
|
36
|
+
Reflect.defineMetadata('calyx:expose_properties', existing, constructor);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function Exclude(): PropertyDecorator {
|
|
41
|
+
return (target, propertyKey) => {
|
|
42
|
+
const constructor = target.constructor;
|
|
43
|
+
const existing = Reflect.getOwnMetadata('calyx:exclude_properties', constructor) || new Set();
|
|
44
|
+
existing.add(String(propertyKey));
|
|
45
|
+
Reflect.defineMetadata('calyx:exclude_properties', existing, constructor);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { PipeTransform, ArgumentMetadata } from '../lifecycle/interfaces.ts';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
4
|
+
import { ValidationCompiler } from './compiler.ts';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ValidationPipe implements PipeTransform {
|
|
8
|
+
async transform(value: any, metadata: ArgumentMetadata) {
|
|
9
|
+
const metatype = metadata.metatype;
|
|
10
|
+
if (!metatype || this.toValidate(metatype)) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const validate = ValidationCompiler.compile(metatype);
|
|
15
|
+
const errors = validate(value);
|
|
16
|
+
if (errors) {
|
|
17
|
+
throw new HttpException({
|
|
18
|
+
statusCode: 400,
|
|
19
|
+
message: 'Validation failed',
|
|
20
|
+
errors,
|
|
21
|
+
}, 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private toValidate(metatype: Function): boolean {
|
|
28
|
+
const types: Function[] = [String, Boolean, Number, Array, Object];
|
|
29
|
+
return types.includes(metatype);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
CalyxFactory,
|
|
7
|
+
CacheModule,
|
|
8
|
+
CacheService,
|
|
9
|
+
CacheInterceptor,
|
|
10
|
+
UseInterceptors,
|
|
11
|
+
} from '../src/index.ts';
|
|
12
|
+
|
|
13
|
+
let calculateCalls = 0;
|
|
14
|
+
|
|
15
|
+
@Controller('math')
|
|
16
|
+
class MathController {
|
|
17
|
+
constructor(private readonly cache: CacheService) {}
|
|
18
|
+
|
|
19
|
+
@Get('slow-calc')
|
|
20
|
+
@UseInterceptors(CacheInterceptor)
|
|
21
|
+
slowCalculation() {
|
|
22
|
+
calculateCalls++;
|
|
23
|
+
return { result: 42, count: calculateCalls };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Module({
|
|
28
|
+
imports: [CacheModule.register({ defaultTtl: 1 })], // 1 second TTL
|
|
29
|
+
controllers: [MathController],
|
|
30
|
+
})
|
|
31
|
+
class TestApp {}
|
|
32
|
+
|
|
33
|
+
describe('SQLite Caching (CacheModule, CacheService, CacheInterceptor)', () => {
|
|
34
|
+
let app: any;
|
|
35
|
+
let baseUrl: string;
|
|
36
|
+
const PORT = 3918;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
calculateCalls = 0;
|
|
40
|
+
app = await CalyxFactory.create(TestApp);
|
|
41
|
+
await app.listen(PORT);
|
|
42
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await app.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('should set, get, and del values directly using CacheService', async () => {
|
|
50
|
+
const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
|
|
51
|
+
|
|
52
|
+
await cacheService.set('foo', 'bar');
|
|
53
|
+
expect(await cacheService.get('foo')).toBe('bar');
|
|
54
|
+
|
|
55
|
+
await cacheService.del('foo');
|
|
56
|
+
expect(await cacheService.get('foo')).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should expire cached key after TTL expires', async () => {
|
|
60
|
+
const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
|
|
61
|
+
|
|
62
|
+
await cacheService.set('expire_test', { val: 123 }, 1); // 1 second TTL
|
|
63
|
+
expect(await cacheService.get('expire_test')).toEqual({ val: 123 });
|
|
64
|
+
|
|
65
|
+
// Wait 1.1s
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
67
|
+
expect(await cacheService.get('expire_test')).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('should cache GET route response and short-circuit subsequent requests', async () => {
|
|
71
|
+
// 1. First call -> should execute handler
|
|
72
|
+
const res1 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
73
|
+
expect(res1.status).toBe(200);
|
|
74
|
+
const body1 = await res1.json();
|
|
75
|
+
expect(body1).toEqual({ result: 42, count: 1 });
|
|
76
|
+
expect(calculateCalls).toBe(1);
|
|
77
|
+
|
|
78
|
+
// 2. Second call -> should return cached response (calculateCalls remains 1)
|
|
79
|
+
const res2 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
80
|
+
expect(res2.status).toBe(200);
|
|
81
|
+
const body2 = await res2.json();
|
|
82
|
+
expect(body2).toEqual({ result: 42, count: 1 });
|
|
83
|
+
expect(calculateCalls).toBe(1);
|
|
84
|
+
|
|
85
|
+
// 3. Wait 1.1s for cache to expire, then call again -> should execute handler again
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
87
|
+
const res3 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
88
|
+
expect(res3.status).toBe(200);
|
|
89
|
+
const body3 = await res3.json();
|
|
90
|
+
expect(body3).toEqual({ result: 42, count: 2 });
|
|
91
|
+
expect(calculateCalls).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Post,
|
|
6
|
+
Body,
|
|
7
|
+
CalyxFactory,
|
|
8
|
+
IsString,
|
|
9
|
+
IsNumber,
|
|
10
|
+
IsEmail,
|
|
11
|
+
IsOptional,
|
|
12
|
+
Exclude,
|
|
13
|
+
Expose,
|
|
14
|
+
ValidationPipe,
|
|
15
|
+
UsePipes,
|
|
16
|
+
} from '../src/index.ts';
|
|
17
|
+
|
|
18
|
+
// 1. DTO for Request Validation
|
|
19
|
+
class CreateUserDto {
|
|
20
|
+
@IsString()
|
|
21
|
+
name!: string;
|
|
22
|
+
|
|
23
|
+
@IsNumber()
|
|
24
|
+
age!: number;
|
|
25
|
+
|
|
26
|
+
@IsEmail()
|
|
27
|
+
email!: string;
|
|
28
|
+
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@IsString()
|
|
31
|
+
bio?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. DTO for Response Serialization
|
|
35
|
+
class UserResponseDto {
|
|
36
|
+
@Expose()
|
|
37
|
+
id: number;
|
|
38
|
+
|
|
39
|
+
@Expose()
|
|
40
|
+
username: string;
|
|
41
|
+
|
|
42
|
+
@Exclude()
|
|
43
|
+
passwordHash: string;
|
|
44
|
+
|
|
45
|
+
constructor(id: number, username: string, passwordHash: string) {
|
|
46
|
+
this.id = id;
|
|
47
|
+
this.username = username;
|
|
48
|
+
this.passwordHash = passwordHash;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Controller('users')
|
|
53
|
+
class UsersController {
|
|
54
|
+
@Post('validate')
|
|
55
|
+
@UsePipes(ValidationPipe)
|
|
56
|
+
createUser(@Body() dto: CreateUserDto) {
|
|
57
|
+
return { received: true, dto };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Post('serialize')
|
|
61
|
+
getUserResponse() {
|
|
62
|
+
return new UserResponseDto(123, 'alice', 'super_secret_sha256_hash');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Module({
|
|
67
|
+
controllers: [UsersController],
|
|
68
|
+
})
|
|
69
|
+
class TestApp {}
|
|
70
|
+
|
|
71
|
+
describe('JIT Validation and Response Serialization', () => {
|
|
72
|
+
let app: any;
|
|
73
|
+
let baseUrl: string;
|
|
74
|
+
const PORT = 3922;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
app = await CalyxFactory.create(TestApp);
|
|
78
|
+
await app.listen(PORT);
|
|
79
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await app.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('should pass validation for valid payload', async () => {
|
|
87
|
+
const res = await fetch(`${baseUrl}/users/validate`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
name: 'Bob',
|
|
92
|
+
age: 25,
|
|
93
|
+
email: 'bob@example.com',
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(201);
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
expect(body.received).toBe(true);
|
|
99
|
+
expect(body.dto).toEqual({
|
|
100
|
+
name: 'Bob',
|
|
101
|
+
age: 25,
|
|
102
|
+
email: 'bob@example.com',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('should fail validation and return 400 with list of errors for invalid payload', async () => {
|
|
107
|
+
const res = await fetch(`${baseUrl}/users/validate`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'content-type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
name: 123, // Should be string
|
|
112
|
+
email: 'invalid-email', // Missing @
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
expect(res.status).toBe(400);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.message).toBe('Validation failed');
|
|
118
|
+
expect(body.errors).toContain('name must be a string');
|
|
119
|
+
expect(body.errors).toContain('age should not be empty');
|
|
120
|
+
expect(body.errors).toContain('email must be a valid email');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('should serialize response using JIT serializer and exclude excluded fields', async () => {
|
|
124
|
+
const res = await fetch(`${baseUrl}/users/serialize`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
});
|
|
127
|
+
expect(res.status).toBe(201);
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
// Excluded fields should be missing, exposed should be present
|
|
130
|
+
expect(body.id).toBe(123);
|
|
131
|
+
expect(body.username).toBe('alice');
|
|
132
|
+
expect(body.passwordHash).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|