@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.
@@ -23,19 +23,29 @@
23
23
  * SOFTWARE.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.Res = exports.Req = exports.Body = exports.Param = exports.Query = exports.Connect = exports.Trace = exports.Head = exports.Options = exports.Delete = exports.Patch = exports.Put = exports.Post = exports.Get = void 0;
26
+ exports.rateLimiterCache = exports.Res = exports.Req = exports.Body = exports.Param = exports.Query = exports.Connect = exports.Trace = exports.Head = exports.Options = exports.Delete = exports.Patch = exports.Put = exports.Post = exports.Get = exports.RateLimiterCache = void 0;
27
27
  exports.Controller = Controller;
28
28
  exports.Use = Use;
29
29
  exports.HttpCode = HttpCode;
30
30
  exports.Header = Header;
31
+ exports.Headers = Headers;
31
32
  exports.Before = Before;
32
33
  exports.After = After;
33
34
  exports.Roles = Roles;
34
35
  exports.Redirect = Redirect;
36
+ exports.Cache = Cache;
37
+ exports.RateLimit = RateLimit;
38
+ exports.ContentType = ContentType;
39
+ exports.Version = Version;
40
+ exports.Timeout = Timeout;
41
+ exports.Log = Log;
35
42
  exports.registerControllers = registerControllers;
36
43
  require("reflect-metadata");
44
+ const logger_utils_1 = require("./logger.utils");
45
+ const response_utils_1 = require("./response.utils");
37
46
  const http_status_codes_1 = require("./http-status-codes");
38
- const exception_utils_1 = require("./exception.utils");
47
+ const express_rate_limit_1 = require("express-rate-limit");
48
+ const cache_utils_1 = require("./cache.utils");
39
49
  // Metadata keys
40
50
  const ROUTES_KEY = Symbol('routes');
41
51
  const MIDDLEWARE_KEY = Symbol('middlewares');
@@ -46,6 +56,67 @@ const BEFORE_KEY = Symbol('before');
46
56
  const AFTER_KEY = Symbol('after');
47
57
  const ROLES_KEY = Symbol('roles');
48
58
  const REDIRECT_KEY = Symbol('redirect');
59
+ const CACHE_KEY = Symbol('cache');
60
+ const RATE_LIMIT_KEY = Symbol('rateLimit');
61
+ const CONTENT_TYPE_KEY = Symbol('contentType');
62
+ const VERSION_KEY = Symbol('version');
63
+ const TIMEOUT_KEY = Symbol('timeout');
64
+ const LOG_KEY = Symbol('log');
65
+ /**
66
+ * RateLimiter cache that uses TTLCache for automatic TTL and LRU handling.
67
+ */
68
+ class RateLimiterCache {
69
+ constructor(maxSize = 100, ttlMs = 5 * 60 * 1000) {
70
+ this.cache = new cache_utils_1.TTLCache({
71
+ maxSize,
72
+ ttlMs
73
+ });
74
+ }
75
+ generateKey(options) {
76
+ return `${options.max}:${options.windowMs}:${options.standardHeaders}:${options.legacyHeaders}`;
77
+ }
78
+ get(options) {
79
+ const key = this.generateKey(options);
80
+ const cached = this.cache.get(key);
81
+ if (cached) {
82
+ return cached.limiter;
83
+ }
84
+ // Create new limiter
85
+ const limiter = (0, express_rate_limit_1.rateLimit)(Object.assign(Object.assign({}, options), { handler: (req, res) => {
86
+ const errorResponse = (0, response_utils_1.createFinalErrorResponse)(req, http_status_codes_1.HttpStatusCodes.TOO_MANY_REQUESTS, 'Too Many Requests');
87
+ res.status(http_status_codes_1.HttpStatusCodes.TOO_MANY_REQUESTS).json(errorResponse);
88
+ } }));
89
+ // Store in cache
90
+ this.cache.set(key, {
91
+ limiter,
92
+ config: key
93
+ });
94
+ return limiter;
95
+ }
96
+ clear() {
97
+ this.cache.clear();
98
+ }
99
+ size() {
100
+ return this.cache.size();
101
+ }
102
+ destroy() {
103
+ this.cache.destroy();
104
+ }
105
+ }
106
+ exports.RateLimiterCache = RateLimiterCache;
107
+ // Global cache instance
108
+ const rateLimiterCache = new RateLimiterCache();
109
+ exports.rateLimiterCache = rateLimiterCache;
110
+ function normalizeHeaderValue(value) {
111
+ if (typeof value === 'undefined')
112
+ return undefined;
113
+ if (typeof value === 'string')
114
+ return value;
115
+ if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
116
+ return value;
117
+ }
118
+ return String(value);
119
+ }
49
120
  /**
50
121
  * Factory function that creates HTTP method decorators.
51
122
  *
@@ -308,8 +379,8 @@ function HttpCode(status) {
308
379
  /**
309
380
  * Decorator that adds a custom HTTP header to the response.
310
381
  *
311
- * @param name - Header name
312
- * @param value - Header value
382
+ * @param header - Header name-value pairs or a single header name and value
383
+ * @param value - Header value if a single header name is provided
313
384
  * @returns Method decorator
314
385
  *
315
386
  * @example
@@ -320,13 +391,44 @@ function HttpCode(status) {
320
391
  * // Response will include the Cache-Control header
321
392
  * return { data: '...' };
322
393
  * }
323
- * ```
324
394
  */
325
395
  function Header(name, value) {
326
- return (target, propertyKey, _descriptor) => {
327
- const headers = Reflect.getMetadata(HEADER_KEY, target, propertyKey) || {};
328
- headers[name] = value;
329
- Reflect.defineMetadata(HEADER_KEY, headers, target, propertyKey);
396
+ return Headers(name, value);
397
+ }
398
+ /**
399
+ * Decorator that adds a custom HTTP headers to the response.
400
+ *
401
+ * @param headers - Header name-value pairs or a single header name and value
402
+ * @param value - Header value if a single header name is provided
403
+ * @returns Method decorator
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * @Get('/data')
408
+ * @Headers('Cache-Control', 'max-age=60')
409
+ * getData() {
410
+ * // Response will include the Cache-Control header
411
+ * return { data: '...' };
412
+ * }
413
+ *
414
+ * @Get('/data/:id')
415
+ * @Headers({
416
+ * 'Cache-Control': 'max-age=60',
417
+ * 'X-Custom-Header': 'custom-value',
418
+ * 'Content-Security-Policy': "default-src 'self'"
419
+ * })
420
+ * getData() {
421
+ * // Response will include all specified headers
422
+ * return { data: '...' };
423
+ * }
424
+ *
425
+ * ```
426
+ */
427
+ function Headers(headers, value) {
428
+ return (target, propertyKey) => {
429
+ const existing = Reflect.getMetadata(HEADER_KEY, target, propertyKey) || {};
430
+ const newHeaders = typeof headers === 'string' ? { [headers]: value } : headers;
431
+ Reflect.defineMetadata(HEADER_KEY, Object.assign(Object.assign({}, existing), newHeaders), target, propertyKey);
330
432
  };
331
433
  }
332
434
  /**
@@ -428,6 +530,162 @@ function Redirect(url, statusCode = 302) {
428
530
  return descriptor;
429
531
  };
430
532
  }
533
+ /**
534
+ * Decorator that adds caching to a route response.
535
+ *
536
+ * @param ttlSeconds - Time to live in seconds for the cache
537
+ * @returns Method decorator
538
+ *
539
+ * @example
540
+ * ```ts
541
+ * @Get('/data')
542
+ * @Cache(300) // Cache for 5 minutes
543
+ * getData() {
544
+ * return { data: 'expensive operation result' };
545
+ * }
546
+ * ```
547
+ */
548
+ function Cache(ttlSeconds) {
549
+ return (target, propertyKey, _descriptor) => {
550
+ Reflect.defineMetadata(CACHE_KEY, { ttlSeconds }, target, propertyKey);
551
+ };
552
+ }
553
+ /**
554
+ * Decorator that applies rate limiting to a route.
555
+ * Note: Requires 'express-rate-limit' package to be installed.
556
+ *
557
+ * @param limit - Maximum number of requests allowed in the window
558
+ * @param windowMs - Time window in milliseconds
559
+ * @returns Method decorator
560
+ *
561
+ * Default Options:
562
+ * - standardHeaders: true
563
+ * - legacyHeaders: false
564
+ *
565
+ * @example
566
+ * ```ts
567
+ * @Post('/login')
568
+ * @RateLimit({ max: 5, windowMs: 60000, standardHeaders: true, legacyHeaders: false }) // 5 requests per minute
569
+ * login(@Body() credentials: LoginDto) {
570
+ * return this.authService.login(credentials);
571
+ * }
572
+ * ```
573
+ */
574
+ function RateLimit(options) {
575
+ const opts = Object.assign({ standardHeaders: true, legacyHeaders: false }, options);
576
+ return (target, propertyKey, _descriptor) => {
577
+ Reflect.defineMetadata(RATE_LIMIT_KEY, opts, target, propertyKey);
578
+ };
579
+ }
580
+ /**
581
+ * Decorator that sets the content type for the response.
582
+ *
583
+ * @param type - MIME type for the response
584
+ * @returns Method decorator
585
+ *
586
+ * @example
587
+ * ```ts
588
+ * @Get('/download')
589
+ * @ContentType('application/pdf')
590
+ * downloadPdf() {
591
+ * return this.fileService.generatePdf();
592
+ * }
593
+ * ```
594
+ */
595
+ function ContentType(type) {
596
+ return (target, propertyKey, _descriptor) => {
597
+ Reflect.defineMetadata(CONTENT_TYPE_KEY, { type }, target, propertyKey);
598
+ };
599
+ }
600
+ /**
601
+ * Decorator that adds API versioning to a route.
602
+ *
603
+ * @param version - Version string for the API endpoint
604
+ * @param options - Versioning options
605
+ * @returns Method decorator
606
+ *
607
+ * Default Options:
608
+ * - addPrefix: true
609
+ * - addHeader: true
610
+ * - headerName: 'X-API-Version'
611
+ *
612
+ * @example
613
+ * ```ts
614
+ *
615
+ * @Get('/users')
616
+ * @Version('v2')
617
+ * getUsersV2() {
618
+ * return this.userService.findAllV2();
619
+ * }
620
+ *
621
+ * @Get('/users')
622
+ * @Version('v2', { addPrefix: true, addHeader: true, headerName: 'X-API-Version' })
623
+ * getUsersV2() {
624
+ * // Route becomes /v2/users
625
+ * return this.userService.findAllV2();
626
+ * }
627
+ * ```
628
+ */
629
+ function Version(version, options) {
630
+ const opts = Object.assign({ addPrefix: true, addHeader: true, headerName: 'X-API-Version' }, options);
631
+ return (target, propertyKey, _descriptor) => {
632
+ Reflect.defineMetadata(VERSION_KEY, { version, options: opts }, target, propertyKey);
633
+ };
634
+ }
635
+ /**
636
+ * Decorator that sets a timeout for route execution.
637
+ *
638
+ * @param ms - Timeout in milliseconds
639
+ * @returns Method decorator
640
+ *
641
+ * @example
642
+ * ```ts
643
+ * @Get('/slow-operation')
644
+ * @Timeout(30000) // 30 second timeout
645
+ * slowOperation() {
646
+ * return this.heavyService.processData();
647
+ * }
648
+ * ```
649
+ */
650
+ function Timeout(ms) {
651
+ return (target, propertyKey, _descriptor) => {
652
+ Reflect.defineMetadata(TIMEOUT_KEY, { ms }, target, propertyKey);
653
+ };
654
+ }
655
+ /**
656
+ * Decorator that adds comprehensive logging to a route.
657
+ *
658
+ * @param options - Logging configuration options
659
+ * @returns Method decorator
660
+ *
661
+ * Default Options:
662
+ * - logEntry: true
663
+ * - logExit: true
664
+ * - logBody: false
665
+ * - logParams: false
666
+ * - logResponse: false
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * @Post('/users')
671
+ * @Log({
672
+ * logEntry: true,
673
+ * logExit: true,
674
+ * logBody: true,
675
+ * logParams: true,
676
+ * logResponse: false
677
+ * })
678
+ * createUser(@Body() userData: any) {
679
+ * return this.userService.create(userData);
680
+ * }
681
+ * ```
682
+ */
683
+ function Log(options) {
684
+ return (target, propertyKey, _descriptor) => {
685
+ const config = Object.assign({ logEntry: true, logExit: true, logBody: false, logParams: false, logResponse: false }, options);
686
+ Reflect.defineMetadata(LOG_KEY, config, target, propertyKey);
687
+ };
688
+ }
431
689
  /**
432
690
  * Registers all controller classes with the provided router.
433
691
  * This function processes all decorators and sets up the Express routes.
@@ -440,25 +698,121 @@ function registerControllers(router, controllers) {
440
698
  const instance = new ControllerClass();
441
699
  const basePath = Reflect.getMetadata('basePath', ControllerClass) || '';
442
700
  const routes = Reflect.getMetadata(ROUTES_KEY, ControllerClass) || [];
701
+ // Get controller-level decorators (fallback values)
702
+ const controllerRateLimit = Reflect.getMetadata(RATE_LIMIT_KEY, ControllerClass);
703
+ const controllerCache = Reflect.getMetadata(CACHE_KEY, ControllerClass);
704
+ const controllerTimeout = Reflect.getMetadata(TIMEOUT_KEY, ControllerClass);
705
+ const controllerVersion = Reflect.getMetadata(VERSION_KEY, ControllerClass);
706
+ const controllerRoles = Reflect.getMetadata(ROLES_KEY, ControllerClass);
707
+ const controllerLogConfig = Reflect.getMetadata(LOG_KEY, ControllerClass);
708
+ const controllerHeaders = Reflect.getMetadata(HEADER_KEY, ControllerClass) || {};
443
709
  routes.forEach(({ path, method, handlerName }) => {
710
+ var _a;
444
711
  const middlewares = Reflect.getMetadata(MIDDLEWARE_KEY, instance, handlerName) || [];
445
712
  const params = Reflect.getMetadata(PARAMS_KEY, instance, handlerName) || [];
446
713
  const httpCode = Reflect.getMetadata(HTTP_CODE_KEY, instance, handlerName);
447
- const headers = Reflect.getMetadata(HEADER_KEY, instance, handlerName) || {};
714
+ // Merge controller-level and method-level headers
715
+ const methodHeaders = Reflect.getMetadata(HEADER_KEY, instance, handlerName) || {};
716
+ const headers = Object.assign(Object.assign({}, controllerHeaders), methodHeaders);
448
717
  const beforeHooks = Reflect.getMetadata(BEFORE_KEY, instance, handlerName) || [];
449
718
  const afterHooks = Reflect.getMetadata(AFTER_KEY, instance, handlerName) || [];
450
- const roles = Reflect.getMetadata(ROLES_KEY, instance, handlerName) || [];
451
719
  const redirect = Reflect.getMetadata(REDIRECT_KEY, instance, handlerName);
720
+ const contentType = Reflect.getMetadata(CONTENT_TYPE_KEY, instance, handlerName);
721
+ // Use method-level decorators if present, otherwise fall back to controller-level
722
+ const roles = Reflect.getMetadata(ROLES_KEY, instance, handlerName) || controllerRoles || [];
723
+ const cache = Reflect.getMetadata(CACHE_KEY, instance, handlerName) || controllerCache;
724
+ const rateLimitOptions = Reflect.getMetadata(RATE_LIMIT_KEY, instance, handlerName) || controllerRateLimit;
725
+ const version = Reflect.getMetadata(VERSION_KEY, instance, handlerName) || controllerVersion;
726
+ const timeout = Reflect.getMetadata(TIMEOUT_KEY, instance, handlerName) || controllerTimeout;
727
+ const logConfig = Reflect.getMetadata(LOG_KEY, instance, handlerName) || controllerLogConfig;
728
+ // Create rate limiter for this specific route if needed
729
+ let rateLimiter = null;
730
+ if (rateLimitOptions) {
731
+ try {
732
+ rateLimiter = rateLimiterCache.get(rateLimitOptions);
733
+ }
734
+ catch (err) {
735
+ (0, logger_utils_1.getLogger)().warn({ err }, 'express-rate-limit not available, skipping rate limiting for this route');
736
+ }
737
+ }
738
+ let finalPath = path;
739
+ if ((_a = version === null || version === void 0 ? void 0 : version.options) === null || _a === void 0 ? void 0 : _a.addPrefix) {
740
+ finalPath = `/${version.version}${path}`;
741
+ }
452
742
  const handler = async (req, res, next) => {
453
743
  var _a, _b, _c, _d;
744
+ // Set start time for duration tracking
745
+ req['startTime'] = Date.now();
746
+ let timeoutId;
747
+ let timedOut = false;
454
748
  try {
749
+ // Handle timeout setup
750
+ if (timeout) {
751
+ timeoutId = setTimeout(() => {
752
+ if (!res.headersSent && !timedOut) {
753
+ timedOut = true;
754
+ const errorResponse = (0, response_utils_1.createFinalErrorResponse)(req, http_status_codes_1.HttpStatusCodes.REQUEST_TIMEOUT, 'Request timed out');
755
+ res.status(http_status_codes_1.HttpStatusCodes.REQUEST_TIMEOUT).json(errorResponse);
756
+ }
757
+ }, timeout.ms);
758
+ }
759
+ if (timedOut)
760
+ return;
761
+ // Handle rate limiting
762
+ if (rateLimiter) {
763
+ await new Promise((resolve, reject) => {
764
+ rateLimiter(req, res, (err) => {
765
+ if (err)
766
+ reject(err);
767
+ else
768
+ resolve();
769
+ });
770
+ });
771
+ }
772
+ // Handle content type
773
+ if (!res.headersSent && contentType) {
774
+ res.setHeader('Content-Type', contentType.type);
775
+ }
776
+ // Handle versioning header
777
+ 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)) {
778
+ if (!res.headersSent) {
779
+ res.setHeader(version.options.headerName, version.version);
780
+ }
781
+ }
782
+ // Handle caching
783
+ if (cache && !res.headersSent) {
784
+ res.setHeader('Cache-Control', `public, max-age=${cache.ttlSeconds}`);
785
+ }
786
+ // Handle logging - entry
787
+ if (logConfig === null || logConfig === void 0 ? void 0 : logConfig.logEntry) {
788
+ const logger = (0, logger_utils_1.getLogger)();
789
+ const logData = {
790
+ method: req.method,
791
+ url: req.originalUrl || req.url,
792
+ userAgent: req.get('User-Agent')
793
+ };
794
+ if (logConfig.logParams) {
795
+ logData.params = req.params;
796
+ logData.query = req.query;
797
+ }
798
+ if (logConfig.logBody)
799
+ logData.body = req.body;
800
+ logger.info({ entry: logData }, 'Route Entry:');
801
+ }
455
802
  // Handle roles-based access control
456
- 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)))) {
457
- res.status(http_status_codes_1.HttpStatusCodes.FORBIDDEN).json(new exception_utils_1.ForbiddenException('Forbidden Insufficient Roles'));
803
+ 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)))) {
804
+ const errorResponse = (0, response_utils_1.createFinalErrorResponse)(req, http_status_codes_1.HttpStatusCodes.FORBIDDEN, 'Forbidden Insufficient Roles');
805
+ res.status(http_status_codes_1.HttpStatusCodes.FORBIDDEN).json(errorResponse);
806
+ if (timeoutId) {
807
+ clearTimeout(timeoutId);
808
+ }
458
809
  return;
459
810
  }
460
811
  // Process static redirect if configured
461
812
  if (redirect && redirect.url) {
813
+ if (timeoutId) {
814
+ clearTimeout(timeoutId);
815
+ }
462
816
  return res.redirect(redirect.statusCode, redirect.url);
463
817
  }
464
818
  for (const fn of beforeHooks)
@@ -489,6 +843,12 @@ function registerControllers(router, controllers) {
489
843
  const result = instance[handlerName](...args);
490
844
  // Support both sync and async handlers
491
845
  const awaited = result instanceof Promise ? await result : result;
846
+ // Clear timeout if operation completed
847
+ if (timeoutId) {
848
+ clearTimeout(timeoutId);
849
+ }
850
+ if (timedOut)
851
+ return;
492
852
  // Handle dynamic redirects
493
853
  if (redirect && awaited && typeof awaited === 'object' && 'url' in awaited) {
494
854
  const redirectUrl = awaited.url;
@@ -497,19 +857,39 @@ function registerControllers(router, controllers) {
497
857
  }
498
858
  if (!res.headersSent && typeof awaited !== 'undefined') {
499
859
  if (httpCode)
500
- (_c = res.status) === null || _c === void 0 ? void 0 : _c.call(res, httpCode);
501
- for (const [k, v] of Object.entries(headers))
502
- (_d = res.set) === null || _d === void 0 ? void 0 : _d.call(res, k, v);
860
+ res.status(httpCode);
861
+ for (const [k, v] of Object.entries(headers)) {
862
+ const normalized = normalizeHeaderValue(v);
863
+ if (typeof normalized !== 'undefined') {
864
+ res.set(k, normalized);
865
+ }
866
+ }
503
867
  res.json(awaited);
504
868
  }
869
+ // Handle logging - exit
870
+ if (logConfig === null || logConfig === void 0 ? void 0 : logConfig.logExit) {
871
+ const logger = (0, logger_utils_1.getLogger)();
872
+ const logData = {
873
+ method: req.method,
874
+ url: req.originalUrl || req.url,
875
+ statusCode: res.statusCode,
876
+ duration: `${Date.now() - req.startTime}ms`
877
+ };
878
+ if (logConfig.logResponse)
879
+ logData.response = awaited;
880
+ logger.info({ exit: logData }, 'Route Exit:');
881
+ }
505
882
  for (const fn of afterHooks)
506
883
  await fn(req, res, awaited);
507
884
  }
508
885
  catch (err) {
886
+ if (timeoutId) {
887
+ clearTimeout(timeoutId);
888
+ }
509
889
  next(err);
510
890
  }
511
891
  };
512
- router[method](basePath + path, ...middlewares, handler);
892
+ router[method](basePath + finalPath, ...middlewares, handler);
513
893
  });
514
894
  });
515
895
  }