@catbee/utils 0.0.8-rc.0 → 0.0.8-rc.1

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.
@@ -22,8 +22,11 @@
22
22
  * SOFTWARE.
23
23
  */
24
24
  import 'reflect-metadata';
25
+ import { getLogger } from './logger.utils';
26
+ import { createFinalErrorResponse } from './response.utils';
25
27
  import { HttpStatusCodes } from './http-status-codes';
26
- import { ForbiddenException } from './exception.utils';
28
+ import { rateLimit } from 'express-rate-limit';
29
+ import { TTLCache } from './cache.utils';
27
30
  // Metadata keys
28
31
  const ROUTES_KEY = Symbol('routes');
29
32
  const MIDDLEWARE_KEY = Symbol('middlewares');
@@ -34,6 +37,65 @@ const BEFORE_KEY = Symbol('before');
34
37
  const AFTER_KEY = Symbol('after');
35
38
  const ROLES_KEY = Symbol('roles');
36
39
  const REDIRECT_KEY = Symbol('redirect');
40
+ const CACHE_KEY = Symbol('cache');
41
+ const RATE_LIMIT_KEY = Symbol('rateLimit');
42
+ const CONTENT_TYPE_KEY = Symbol('contentType');
43
+ const VERSION_KEY = Symbol('version');
44
+ const TIMEOUT_KEY = Symbol('timeout');
45
+ const LOG_KEY = Symbol('log');
46
+ /**
47
+ * RateLimiter cache that uses TTLCache for automatic TTL and LRU handling.
48
+ */
49
+ export class RateLimiterCache {
50
+ constructor(maxSize = 100, ttlMs = 5 * 60 * 1000) {
51
+ this.cache = new TTLCache({
52
+ maxSize,
53
+ ttlMs
54
+ });
55
+ }
56
+ generateKey(options) {
57
+ return `${options.max}:${options.windowMs}:${options.standardHeaders}:${options.legacyHeaders}`;
58
+ }
59
+ get(options) {
60
+ const key = this.generateKey(options);
61
+ const cached = this.cache.get(key);
62
+ if (cached) {
63
+ return cached.limiter;
64
+ }
65
+ // Create new limiter
66
+ const limiter = rateLimit(Object.assign(Object.assign({}, options), { handler: (req, res) => {
67
+ const errorResponse = createFinalErrorResponse(req, HttpStatusCodes.TOO_MANY_REQUESTS, 'Too Many Requests');
68
+ res.status(HttpStatusCodes.TOO_MANY_REQUESTS).json(errorResponse);
69
+ } }));
70
+ // Store in cache
71
+ this.cache.set(key, {
72
+ limiter,
73
+ config: key
74
+ });
75
+ return limiter;
76
+ }
77
+ clear() {
78
+ this.cache.clear();
79
+ }
80
+ size() {
81
+ return this.cache.size();
82
+ }
83
+ destroy() {
84
+ this.cache.destroy();
85
+ }
86
+ }
87
+ // Global cache instance
88
+ const rateLimiterCache = new RateLimiterCache();
89
+ function normalizeHeaderValue(value) {
90
+ if (typeof value === 'undefined')
91
+ return undefined;
92
+ if (typeof value === 'string')
93
+ return value;
94
+ if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
95
+ return value;
96
+ }
97
+ return String(value);
98
+ }
37
99
  /**
38
100
  * Factory function that creates HTTP method decorators.
39
101
  *
@@ -296,8 +358,8 @@ export function HttpCode(status) {
296
358
  /**
297
359
  * Decorator that adds a custom HTTP header to the response.
298
360
  *
299
- * @param name - Header name
300
- * @param value - Header value
361
+ * @param header - Header name-value pairs or a single header name and value
362
+ * @param value - Header value if a single header name is provided
301
363
  * @returns Method decorator
302
364
  *
303
365
  * @example
@@ -308,13 +370,44 @@ export function HttpCode(status) {
308
370
  * // Response will include the Cache-Control header
309
371
  * return { data: '...' };
310
372
  * }
311
- * ```
312
373
  */
313
374
  export function Header(name, value) {
314
- return (target, propertyKey, _descriptor) => {
315
- const headers = Reflect.getMetadata(HEADER_KEY, target, propertyKey) || {};
316
- headers[name] = value;
317
- Reflect.defineMetadata(HEADER_KEY, headers, target, propertyKey);
375
+ return Headers(name, value);
376
+ }
377
+ /**
378
+ * Decorator that adds a custom HTTP headers to the response.
379
+ *
380
+ * @param headers - Header name-value pairs or a single header name and value
381
+ * @param value - Header value if a single header name is provided
382
+ * @returns Method decorator
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * @Get('/data')
387
+ * @Headers('Cache-Control', 'max-age=60')
388
+ * getData() {
389
+ * // Response will include the Cache-Control header
390
+ * return { data: '...' };
391
+ * }
392
+ *
393
+ * @Get('/data/:id')
394
+ * @Headers({
395
+ * 'Cache-Control': 'max-age=60',
396
+ * 'X-Custom-Header': 'custom-value',
397
+ * 'Content-Security-Policy': "default-src 'self'"
398
+ * })
399
+ * getData() {
400
+ * // Response will include all specified headers
401
+ * return { data: '...' };
402
+ * }
403
+ *
404
+ * ```
405
+ */
406
+ export function Headers(headers, value) {
407
+ return (target, propertyKey) => {
408
+ const existing = Reflect.getMetadata(HEADER_KEY, target, propertyKey) || {};
409
+ const newHeaders = typeof headers === 'string' ? { [headers]: value } : headers;
410
+ Reflect.defineMetadata(HEADER_KEY, Object.assign(Object.assign({}, existing), newHeaders), target, propertyKey);
318
411
  };
319
412
  }
320
413
  /**
@@ -416,6 +509,162 @@ export function Redirect(url, statusCode = 302) {
416
509
  return descriptor;
417
510
  };
418
511
  }
512
+ /**
513
+ * Decorator that adds caching to a route response.
514
+ *
515
+ * @param ttlSeconds - Time to live in seconds for the cache
516
+ * @returns Method decorator
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * @Get('/data')
521
+ * @Cache(300) // Cache for 5 minutes
522
+ * getData() {
523
+ * return { data: 'expensive operation result' };
524
+ * }
525
+ * ```
526
+ */
527
+ export function Cache(ttlSeconds) {
528
+ return (target, propertyKey, _descriptor) => {
529
+ Reflect.defineMetadata(CACHE_KEY, { ttlSeconds }, target, propertyKey);
530
+ };
531
+ }
532
+ /**
533
+ * Decorator that applies rate limiting to a route.
534
+ * Note: Requires 'express-rate-limit' package to be installed.
535
+ *
536
+ * @param limit - Maximum number of requests allowed in the window
537
+ * @param windowMs - Time window in milliseconds
538
+ * @returns Method decorator
539
+ *
540
+ * Default Options:
541
+ * - standardHeaders: true
542
+ * - legacyHeaders: false
543
+ *
544
+ * @example
545
+ * ```ts
546
+ * @Post('/login')
547
+ * @RateLimit({ max: 5, windowMs: 60000, standardHeaders: true, legacyHeaders: false }) // 5 requests per minute
548
+ * login(@Body() credentials: LoginDto) {
549
+ * return this.authService.login(credentials);
550
+ * }
551
+ * ```
552
+ */
553
+ export function RateLimit(options) {
554
+ const opts = Object.assign({ standardHeaders: true, legacyHeaders: false }, options);
555
+ return (target, propertyKey, _descriptor) => {
556
+ Reflect.defineMetadata(RATE_LIMIT_KEY, opts, target, propertyKey);
557
+ };
558
+ }
559
+ /**
560
+ * Decorator that sets the content type for the response.
561
+ *
562
+ * @param type - MIME type for the response
563
+ * @returns Method decorator
564
+ *
565
+ * @example
566
+ * ```ts
567
+ * @Get('/download')
568
+ * @ContentType('application/pdf')
569
+ * downloadPdf() {
570
+ * return this.fileService.generatePdf();
571
+ * }
572
+ * ```
573
+ */
574
+ export function ContentType(type) {
575
+ return (target, propertyKey, _descriptor) => {
576
+ Reflect.defineMetadata(CONTENT_TYPE_KEY, { type }, target, propertyKey);
577
+ };
578
+ }
579
+ /**
580
+ * Decorator that adds API versioning to a route.
581
+ *
582
+ * @param version - Version string for the API endpoint
583
+ * @param options - Versioning options
584
+ * @returns Method decorator
585
+ *
586
+ * Default Options:
587
+ * - addPrefix: true
588
+ * - addHeader: true
589
+ * - headerName: 'X-API-Version'
590
+ *
591
+ * @example
592
+ * ```ts
593
+ *
594
+ * @Get('/users')
595
+ * @Version('v2')
596
+ * getUsersV2() {
597
+ * return this.userService.findAllV2();
598
+ * }
599
+ *
600
+ * @Get('/users')
601
+ * @Version('v2', { addPrefix: true, addHeader: true, headerName: 'X-API-Version' })
602
+ * getUsersV2() {
603
+ * // Route becomes /v2/users
604
+ * return this.userService.findAllV2();
605
+ * }
606
+ * ```
607
+ */
608
+ export function Version(version, options) {
609
+ const opts = Object.assign({ addPrefix: true, addHeader: true, headerName: 'X-API-Version' }, options);
610
+ return (target, propertyKey, _descriptor) => {
611
+ Reflect.defineMetadata(VERSION_KEY, { version, options: opts }, target, propertyKey);
612
+ };
613
+ }
614
+ /**
615
+ * Decorator that sets a timeout for route execution.
616
+ *
617
+ * @param ms - Timeout in milliseconds
618
+ * @returns Method decorator
619
+ *
620
+ * @example
621
+ * ```ts
622
+ * @Get('/slow-operation')
623
+ * @Timeout(30000) // 30 second timeout
624
+ * slowOperation() {
625
+ * return this.heavyService.processData();
626
+ * }
627
+ * ```
628
+ */
629
+ export function Timeout(ms) {
630
+ return (target, propertyKey, _descriptor) => {
631
+ Reflect.defineMetadata(TIMEOUT_KEY, { ms }, target, propertyKey);
632
+ };
633
+ }
634
+ /**
635
+ * Decorator that adds comprehensive logging to a route.
636
+ *
637
+ * @param options - Logging configuration options
638
+ * @returns Method decorator
639
+ *
640
+ * Default Options:
641
+ * - logEntry: true
642
+ * - logExit: true
643
+ * - logBody: false
644
+ * - logParams: false
645
+ * - logResponse: false
646
+ *
647
+ * @example
648
+ * ```ts
649
+ * @Post('/users')
650
+ * @Log({
651
+ * logEntry: true,
652
+ * logExit: true,
653
+ * logBody: true,
654
+ * logParams: true,
655
+ * logResponse: false
656
+ * })
657
+ * createUser(@Body() userData: any) {
658
+ * return this.userService.create(userData);
659
+ * }
660
+ * ```
661
+ */
662
+ export function Log(options) {
663
+ return (target, propertyKey, _descriptor) => {
664
+ const config = Object.assign({ logEntry: true, logExit: true, logBody: false, logParams: false, logResponse: false }, options);
665
+ Reflect.defineMetadata(LOG_KEY, config, target, propertyKey);
666
+ };
667
+ }
419
668
  /**
420
669
  * Registers all controller classes with the provided router.
421
670
  * This function processes all decorators and sets up the Express routes.
@@ -428,25 +677,121 @@ export function registerControllers(router, controllers) {
428
677
  const instance = new ControllerClass();
429
678
  const basePath = Reflect.getMetadata('basePath', ControllerClass) || '';
430
679
  const routes = Reflect.getMetadata(ROUTES_KEY, ControllerClass) || [];
680
+ // Get controller-level decorators (fallback values)
681
+ const controllerRateLimit = Reflect.getMetadata(RATE_LIMIT_KEY, ControllerClass);
682
+ const controllerCache = Reflect.getMetadata(CACHE_KEY, ControllerClass);
683
+ const controllerTimeout = Reflect.getMetadata(TIMEOUT_KEY, ControllerClass);
684
+ const controllerVersion = Reflect.getMetadata(VERSION_KEY, ControllerClass);
685
+ const controllerRoles = Reflect.getMetadata(ROLES_KEY, ControllerClass);
686
+ const controllerLogConfig = Reflect.getMetadata(LOG_KEY, ControllerClass);
687
+ const controllerHeaders = Reflect.getMetadata(HEADER_KEY, ControllerClass) || {};
431
688
  routes.forEach(({ path, method, handlerName }) => {
689
+ var _a;
432
690
  const middlewares = Reflect.getMetadata(MIDDLEWARE_KEY, instance, handlerName) || [];
433
691
  const params = Reflect.getMetadata(PARAMS_KEY, instance, handlerName) || [];
434
692
  const httpCode = Reflect.getMetadata(HTTP_CODE_KEY, instance, handlerName);
435
- const headers = Reflect.getMetadata(HEADER_KEY, instance, handlerName) || {};
693
+ // Merge controller-level and method-level headers
694
+ const methodHeaders = Reflect.getMetadata(HEADER_KEY, instance, handlerName) || {};
695
+ const headers = Object.assign(Object.assign({}, controllerHeaders), methodHeaders);
436
696
  const beforeHooks = Reflect.getMetadata(BEFORE_KEY, instance, handlerName) || [];
437
697
  const afterHooks = Reflect.getMetadata(AFTER_KEY, instance, handlerName) || [];
438
- const roles = Reflect.getMetadata(ROLES_KEY, instance, handlerName) || [];
439
698
  const redirect = Reflect.getMetadata(REDIRECT_KEY, instance, handlerName);
699
+ const contentType = Reflect.getMetadata(CONTENT_TYPE_KEY, instance, handlerName);
700
+ // Use method-level decorators if present, otherwise fall back to controller-level
701
+ const roles = Reflect.getMetadata(ROLES_KEY, instance, handlerName) || controllerRoles || [];
702
+ const cache = Reflect.getMetadata(CACHE_KEY, instance, handlerName) || controllerCache;
703
+ const rateLimitOptions = Reflect.getMetadata(RATE_LIMIT_KEY, instance, handlerName) || controllerRateLimit;
704
+ const version = Reflect.getMetadata(VERSION_KEY, instance, handlerName) || controllerVersion;
705
+ const timeout = Reflect.getMetadata(TIMEOUT_KEY, instance, handlerName) || controllerTimeout;
706
+ const logConfig = Reflect.getMetadata(LOG_KEY, instance, handlerName) || controllerLogConfig;
707
+ // Create rate limiter for this specific route if needed
708
+ let rateLimiter = null;
709
+ if (rateLimitOptions) {
710
+ try {
711
+ rateLimiter = rateLimiterCache.get(rateLimitOptions);
712
+ }
713
+ catch (err) {
714
+ getLogger().warn({ err }, 'express-rate-limit not available, skipping rate limiting for this route');
715
+ }
716
+ }
717
+ let finalPath = path;
718
+ if ((_a = version === null || version === void 0 ? void 0 : version.options) === null || _a === void 0 ? void 0 : _a.addPrefix) {
719
+ finalPath = `/${version.version}${path}`;
720
+ }
440
721
  const handler = async (req, res, next) => {
441
722
  var _a, _b, _c, _d;
723
+ // Set start time for duration tracking
724
+ req['startTime'] = Date.now();
725
+ let timeoutId;
726
+ let timedOut = false;
442
727
  try {
728
+ // Handle timeout setup
729
+ if (timeout) {
730
+ timeoutId = setTimeout(() => {
731
+ if (!res.headersSent && !timedOut) {
732
+ timedOut = true;
733
+ const errorResponse = createFinalErrorResponse(req, HttpStatusCodes.REQUEST_TIMEOUT, 'Request timed out');
734
+ res.status(HttpStatusCodes.REQUEST_TIMEOUT).json(errorResponse);
735
+ }
736
+ }, timeout.ms);
737
+ }
738
+ if (timedOut)
739
+ return;
740
+ // Handle rate limiting
741
+ if (rateLimiter) {
742
+ await new Promise((resolve, reject) => {
743
+ rateLimiter(req, res, (err) => {
744
+ if (err)
745
+ reject(err);
746
+ else
747
+ resolve();
748
+ });
749
+ });
750
+ }
751
+ // Handle content type
752
+ if (!res.headersSent && contentType) {
753
+ res.setHeader('Content-Type', contentType.type);
754
+ }
755
+ // Handle versioning header
756
+ if (((_a = version === null || version === void 0 ? void 0 : version.options) === null || _a === void 0 ? void 0 : _a.addHeader) && ((_b = version === null || version === void 0 ? void 0 : version.options) === null || _b === void 0 ? void 0 : _b.headerName) && (version === null || version === void 0 ? void 0 : version.version)) {
757
+ if (!res.headersSent) {
758
+ res.setHeader(version.options.headerName, version.version);
759
+ }
760
+ }
761
+ // Handle caching
762
+ if (cache && !res.headersSent) {
763
+ res.setHeader('Cache-Control', `public, max-age=${cache.ttlSeconds}`);
764
+ }
765
+ // Handle logging - entry
766
+ if (logConfig === null || logConfig === void 0 ? void 0 : logConfig.logEntry) {
767
+ const logger = getLogger();
768
+ const logData = {
769
+ method: req.method,
770
+ url: req.originalUrl || req.url,
771
+ userAgent: req.get('User-Agent')
772
+ };
773
+ if (logConfig.logParams) {
774
+ logData.params = req.params;
775
+ logData.query = req.query;
776
+ }
777
+ if (logConfig.logBody)
778
+ logData.body = req.body;
779
+ logger.info({ entry: logData }, 'Route Entry:');
780
+ }
443
781
  // Handle roles-based access control
444
- if (roles.length && !((_b = (_a = req === null || req === void 0 ? void 0 : req.user) === null || _a === void 0 ? void 0 : _a.roles) === null || _b === void 0 ? void 0 : _b.some((role) => roles.includes(role)))) {
445
- res.status(HttpStatusCodes.FORBIDDEN).json(new ForbiddenException('Forbidden Insufficient Roles'));
782
+ if (roles.length && !((_d = (_c = req === null || req === void 0 ? void 0 : req.user) === null || _c === void 0 ? void 0 : _c.roles) === null || _d === void 0 ? void 0 : _d.some((role) => roles.includes(role)))) {
783
+ const errorResponse = createFinalErrorResponse(req, HttpStatusCodes.FORBIDDEN, 'Forbidden Insufficient Roles');
784
+ res.status(HttpStatusCodes.FORBIDDEN).json(errorResponse);
785
+ if (timeoutId) {
786
+ clearTimeout(timeoutId);
787
+ }
446
788
  return;
447
789
  }
448
790
  // Process static redirect if configured
449
791
  if (redirect && redirect.url) {
792
+ if (timeoutId) {
793
+ clearTimeout(timeoutId);
794
+ }
450
795
  return res.redirect(redirect.statusCode, redirect.url);
451
796
  }
452
797
  for (const fn of beforeHooks)
@@ -477,6 +822,12 @@ export function registerControllers(router, controllers) {
477
822
  const result = instance[handlerName](...args);
478
823
  // Support both sync and async handlers
479
824
  const awaited = result instanceof Promise ? await result : result;
825
+ // Clear timeout if operation completed
826
+ if (timeoutId) {
827
+ clearTimeout(timeoutId);
828
+ }
829
+ if (timedOut)
830
+ return;
480
831
  // Handle dynamic redirects
481
832
  if (redirect && awaited && typeof awaited === 'object' && 'url' in awaited) {
482
833
  const redirectUrl = awaited.url;
@@ -485,20 +836,42 @@ export function registerControllers(router, controllers) {
485
836
  }
486
837
  if (!res.headersSent && typeof awaited !== 'undefined') {
487
838
  if (httpCode)
488
- (_c = res.status) === null || _c === void 0 ? void 0 : _c.call(res, httpCode);
489
- for (const [k, v] of Object.entries(headers))
490
- (_d = res.set) === null || _d === void 0 ? void 0 : _d.call(res, k, v);
839
+ res.status(httpCode);
840
+ for (const [k, v] of Object.entries(headers)) {
841
+ const normalized = normalizeHeaderValue(v);
842
+ if (typeof normalized !== 'undefined') {
843
+ res.set(k, normalized);
844
+ }
845
+ }
491
846
  res.json(awaited);
492
847
  }
848
+ // Handle logging - exit
849
+ if (logConfig === null || logConfig === void 0 ? void 0 : logConfig.logExit) {
850
+ const logger = getLogger();
851
+ const logData = {
852
+ method: req.method,
853
+ url: req.originalUrl || req.url,
854
+ statusCode: res.statusCode,
855
+ duration: `${Date.now() - req.startTime}ms`
856
+ };
857
+ if (logConfig.logResponse)
858
+ logData.response = awaited;
859
+ logger.info({ exit: logData }, 'Route Exit:');
860
+ }
493
861
  for (const fn of afterHooks)
494
862
  await fn(req, res, awaited);
495
863
  }
496
864
  catch (err) {
865
+ if (timeoutId) {
866
+ clearTimeout(timeoutId);
867
+ }
497
868
  next(err);
498
869
  }
499
870
  };
500
- router[method](basePath + path, ...middlewares, handler);
871
+ router[method](basePath + finalPath, ...middlewares, handler);
501
872
  });
502
873
  });
503
874
  }
875
+ // Export for testing purposes
876
+ export { rateLimiterCache };
504
877
  //# sourceMappingURL=decorators.utils.js.map