Skip to content

Commit 76bbd2c

Browse files
committed
feat(decorators): add @ReqCookie and @ReqId decorators for extracting cookies and request IDs
1 parent dd3c300 commit 76bbd2c

4 files changed

Lines changed: 209 additions & 15 deletions

File tree

docs/docs/utils/decorators.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ These utilities provide a declarative way to define Express routes, middleware,
2323
- [**`@Req(): ParameterDecorator`**](#req) - Extract the request object.
2424
- [**`@Res(): ParameterDecorator`**](#res) - Extract the response object.
2525
- [**`@ReqHeader(key?: string): ParameterDecorator`**](#reqheader) - Extract request headers.
26+
- [**`@ReqCookie(name?: string): ParameterDecorator`**](#reqcookie) - Extract cookies from the request.
2627
- [**`@ReqLogger(): ParameterDecorator`**](#reqlogger) - Inject a logger instance.
28+
- [**`@ReqId(): ParameterDecorator`**](#reqid) - Extract the request ID from headers or request object.
2729
- [**`@HttpCode(status: number): MethodDecorator`**](#httpcode) - Set a custom HTTP status code for the response.
2830
- [**`@Header(name: string, value: string): MethodDecorator & ClassDecorator`**](#header) - Add a custom HTTP header to the response.
2931
- [**`@Headers(headers: Record<string, string> | string, value?: string): MethodDecorator & ClassDecorator`**](#headers) - Add multiple custom HTTP headers to the response.
@@ -83,7 +85,7 @@ interface ParamDefinition {
8385

8486
// Parameter options for advanced extraction
8587
interface ParamOptions<T = any> {
86-
type: 'string' | 'number' | 'boolean';
88+
type?: 'string' | 'number' | 'boolean';
8789
dataType?: 'single' | 'array' | 'object';
8890
delimiter?: string;
8991
default?: T;
@@ -689,6 +691,32 @@ getData(
689691

690692
---
691693

694+
### `@ReqCookie()`
695+
Extracts cookies from the request.
696+
697+
**Method Signature:**
698+
```ts
699+
@ReqCookie(name?: string): ParameterDecorator
700+
```
701+
702+
**Parameters:**
703+
- `name`: The name of the cookie to extract.
704+
705+
**Returns:**
706+
- A parameter decorator.
707+
708+
**Examples:**
709+
```ts
710+
import { Get, ReqCookie } from '@catbee/utils';
711+
712+
@Get('/data')
713+
getData(@ReqCookie('session_id') sessionId: string) {
714+
return { sessionId };
715+
}
716+
```
717+
718+
---
719+
692720
### `@ReqLogger()`
693721
Injects a logger instance.
694722

@@ -719,6 +747,29 @@ createItem(@Body() data: any, @ReqLogger() logger: any) {
719747

720748
---
721749

750+
### `@ReqId()`
751+
Extracts the request ID from the request headers or the request object.
752+
753+
**Method Signature:**
754+
```ts
755+
@ReqId(): ParameterDecorator
756+
```
757+
758+
**Returns:**
759+
- A parameter decorator.
760+
761+
**Examples:**
762+
```ts
763+
import { Get, ReqId } from '@catbee/utils';
764+
765+
@Get('/data')
766+
getData(@ReqId() reqId: string) {
767+
return { reqId };
768+
}
769+
```
770+
771+
---
772+
722773
### `@HttpCode()`
723774
Sets a custom HTTP status code for the response.
724775

src/utils/decorators.utils.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ interface ParamDefinition {
7272
/** Parameter position in method signature */
7373
index: number;
7474
/** Type of parameter (query, body, etc.) */
75-
type: 'query' | 'param' | 'body' | 'req' | 'res' | 'logger' | 'reqHeader';
75+
type: 'query' | 'param' | 'body' | 'req' | 'res' | 'logger' | 'reqHeader' | 'reqId' | 'cookie';
7676
/** Optional key for extracting specific property */
7777
key?: string;
7878
/** Optional ParamOptions for advanced extraction */
@@ -504,7 +504,7 @@ export function Use(...middlewares: RequestHandler[]): MethodDecorator & ClassDe
504504
*/
505505
export interface ParamOptions<T = any> {
506506
/** Base type of the parameter (default: 'string') */
507-
type: 'string' | 'number' | 'boolean';
507+
type?: 'string' | 'number' | 'boolean';
508508

509509
/** Data structure type (default: 'single') */
510510
dataType?: 'single' | 'array' | 'object';
@@ -540,14 +540,36 @@ export interface ParamOptions<T = any> {
540540
transform?: (value: any) => any;
541541
}
542542

543-
function createParamDecorator(type: ParamDefinition['type'], key?: string) {
543+
function defineParamMetadata(
544+
target: any,
545+
propertyKey: string | symbol,
546+
parameterIndex: number,
547+
type: ParamDefinition['type'],
548+
key: string | undefined
549+
) {
550+
const params: ParamDefinition[] = Reflect.getMetadata(PARAMS_KEY, target, propertyKey) || [];
551+
552+
params.push({ index: parameterIndex, type, key });
553+
params.sort((a, b) => a.index - b.index);
554+
555+
Reflect.defineMetadata(PARAMS_KEY, params, target, propertyKey);
556+
}
557+
558+
export function createParamDecorator(
559+
type: ParamDefinition['type'],
560+
key?: string
561+
): (paramKey?: string) => ParameterDecorator {
544562
return (paramKey?: string): ParameterDecorator => {
545563
return (target, propertyKey, parameterIndex) => {
546-
const params: ParamDefinition[] = Reflect.getMetadata(PARAMS_KEY, target as object, propertyKey as string) || [];
547-
// Ensure parameters are ordered by index
548-
params.push({ index: parameterIndex, type, key: paramKey || key });
549-
params.sort((a, b) => a.index - b.index);
550-
Reflect.defineMetadata(PARAMS_KEY, params, target as object, propertyKey as string);
564+
defineParamMetadata(target, propertyKey as string, parameterIndex, type, paramKey ?? key);
565+
};
566+
};
567+
}
568+
569+
export function createParamDecoratorWithoutParam(type: ParamDefinition['type']): () => ParameterDecorator {
570+
return (): ParameterDecorator => {
571+
return (target, propertyKey, parameterIndex) => {
572+
defineParamMetadata(target, propertyKey as string, parameterIndex, type, undefined);
551573
};
552574
};
553575
}
@@ -652,7 +674,21 @@ export const Body = createParamDecorator('body');
652674
* }
653675
* ```
654676
*/
655-
export const ReqLogger = createParamDecorator('logger');
677+
export const ReqLogger = createParamDecoratorWithoutParam('logger');
678+
679+
/**
680+
* Decorator that extracts request ID from headers.
681+
* @returns Parameter decorator
682+
*
683+
* @example
684+
* ```ts
685+
* @Get('/data')
686+
* getData(@ReqId() reqId: string) {
687+
* // reqId will contain the value of req.headers['x-request-id'] or req.id
688+
* }
689+
* ```
690+
*/
691+
export const ReqId = createParamDecoratorWithoutParam('reqId');
656692

657693
/**
658694
* Decorator that extracts request headers.
@@ -669,6 +705,21 @@ export const ReqLogger = createParamDecorator('logger');
669705
*/
670706
export const ReqHeader = createParamDecorator('reqHeader');
671707

708+
/**
709+
* Decorator that extracts cookies from request.
710+
* @param key - Optional key to extract specific cookie
711+
* @returns Parameter decorator
712+
*
713+
* @example
714+
* ```ts
715+
* @Get('/data')
716+
* getData(@ReqCookie('session_id') sessionId: string) {
717+
* // sessionId will contain the value of req.cookies['session_id']
718+
* }
719+
* ```
720+
*/
721+
export const ReqCookie = createParamDecorator('cookie');
722+
672723
/**
673724
* Decorator that injects the entire request object.
674725
*
@@ -683,7 +734,7 @@ export const ReqHeader = createParamDecorator('reqHeader');
683734
* }
684735
* ```
685736
*/
686-
export const Req = createParamDecorator('req');
737+
export const Req = createParamDecoratorWithoutParam('req');
687738

688739
/**
689740
* Decorator that injects the response object.
@@ -699,7 +750,7 @@ export const Req = createParamDecorator('req');
699750
* }
700751
* ```
701752
*/
702-
export const Res = createParamDecorator('res');
753+
export const Res = createParamDecoratorWithoutParam('res');
703754

704755
/**
705756
* Decorator that sets a custom HTTP status code for a response.
@@ -1387,6 +1438,12 @@ export function registerControllers(router: Router, controllers: any[]) {
13871438
case 'reqHeader':
13881439
args[index] = key ? req.headers[key.toLowerCase()] : req.headers;
13891440
break;
1441+
case 'reqId':
1442+
args[index] = req.headers['x-request-id'] || req?.id || undefined;
1443+
break;
1444+
case 'cookie':
1445+
args[index] = key ? req.cookies?.[key] : req.cookies;
1446+
break;
13901447
}
13911448
});
13921449
}
@@ -1529,7 +1586,7 @@ function applyParamOptions(rawValue: any, options: ParamOptions, key?: string) {
15291586

15301587
// Apply type conversion to each array element
15311588
if (options.type) {
1532-
value = value.map((v: any) => convertType(v, options.type));
1589+
value = value.map((v: any) => convertType(v, options.type || 'string'));
15331590
}
15341591
} else if (options.dataType === 'object') {
15351592
// Handle object data type

tests/utils/decorator.utils.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import {
2727
registerControllers,
2828
Injectable,
2929
Inject,
30-
ReqHeader
30+
ReqHeader,
31+
ReqCookie,
32+
ReqId
3133
} from '../../src/utils/decorators.utils';
3234
import { jest } from '@jest/globals';
3335
import { HttpStatusCodes } from '../../src/utils/http-status-codes';
@@ -153,6 +155,79 @@ describe('Decorators and registerControllers', () => {
153155
});
154156
});
155157

158+
it('should extract request ID with @ReqId and respect options', async () => {
159+
mockReq.headers['x-request-id'] = 'test-id-123';
160+
161+
@Controller('/reqid-options')
162+
class ReqIdOptionsController {
163+
@Get('/test')
164+
testReqId(@ReqId() plainReqId: string) {
165+
return { plainReqId };
166+
}
167+
}
168+
169+
registerControllers(mockRouter, [ReqIdOptionsController]);
170+
const [, ...handlers] = mockRouter.get.mock.calls[0];
171+
const routeHandler = handlers[handlers.length - 1];
172+
173+
await routeHandler(mockReq, mockRes, mockNext);
174+
expect(mockRes.json).toHaveBeenCalledWith({
175+
plainReqId: 'test-id-123'
176+
});
177+
178+
// Test with request ID from req.id property
179+
mockReq.headers['x-request-id'] = undefined;
180+
mockReq.id = 'test-id-456';
181+
182+
await routeHandler(mockReq, mockRes, mockNext);
183+
expect(mockRes.json).toHaveBeenCalledWith({
184+
plainReqId: 'test-id-456'
185+
});
186+
187+
// Test with missing request ID
188+
mockReq.headers['x-request-id'] = undefined;
189+
mockReq.id = undefined;
190+
mockRes.json = jest.fn();
191+
192+
await routeHandler(mockReq, mockRes, mockNext);
193+
194+
expect(mockRes.json).toHaveBeenCalledWith({ plainReqId: undefined });
195+
});
196+
197+
it('should extract cookies with @ReqCookie and respect options', async () => {
198+
mockReq.cookies = {
199+
JSESSION: 'header.payload.signature'
200+
};
201+
202+
@Controller('/cookie-options')
203+
class CookieOptionsController {
204+
@Get('/test')
205+
testCookieOptions(
206+
@ReqCookie('JSESSION') sessionToken: string,
207+
@ReqCookie('missing') missingCookie: string,
208+
@ReqCookie() allCookies: Record<string, string>
209+
) {
210+
return {
211+
sessionToken,
212+
missingCookie,
213+
allCookies
214+
};
215+
}
216+
}
217+
218+
registerControllers(mockRouter, [CookieOptionsController]);
219+
const [, ...handlers] = mockRouter.get.mock.calls[0];
220+
const routeHandler = handlers[handlers.length - 1];
221+
222+
await routeHandler(mockReq, mockRes, mockNext);
223+
224+
expect(mockRes.json).toHaveBeenCalledWith({
225+
sessionToken: 'header.payload.signature',
226+
missingCookie: undefined,
227+
allCookies: { JSESSION: 'header.payload.signature' }
228+
});
229+
});
230+
156231
it('should handle async route handlers', async () => {
157232
@Controller('/async')
158233
class AsyncController {

tests/utils/exception.utils.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,21 @@ describe('ExceptionUtils', () => {
263263
it('returns correct error class for known status', () => {
264264
expect(createHttpError(HttpStatusCodes.BAD_REQUEST)).toBeInstanceOf(BadRequestException);
265265
expect(createHttpError(HttpStatusCodes.UNAUTHORIZED)).toBeInstanceOf(UnauthorizedException);
266+
expect(createHttpError(HttpStatusCodes.FORBIDDEN)).toBeInstanceOf(ForbiddenException);
266267
expect(createHttpError(HttpStatusCodes.NOT_FOUND)).toBeInstanceOf(NotFoundException);
267268
expect(createHttpError(HttpStatusCodes.METHOD_NOT_ALLOWED)).toBeInstanceOf(MethodNotAllowedException);
269+
expect(createHttpError(HttpStatusCodes.NOT_ACCEPTABLE)).toBeInstanceOf(NotAcceptableException);
270+
expect(createHttpError(HttpStatusCodes.REQUEST_TIMEOUT)).toBeInstanceOf(RequestTimeoutException);
271+
expect(createHttpError(HttpStatusCodes.CONFLICT)).toBeInstanceOf(ConflictException);
272+
expect(createHttpError(HttpStatusCodes.PAYLOAD_TOO_LARGE)).toBeInstanceOf(PayloadTooLargeException);
273+
expect(createHttpError(HttpStatusCodes.UNSUPPORTED_MEDIA_TYPE)).toBeInstanceOf(UnsupportedMediaTypeException);
268274
expect(createHttpError(HttpStatusCodes.UNPROCESSABLE_ENTITY)).toBeInstanceOf(UnprocessableEntityException);
269-
expect(createHttpError(499, 'Custom')).toBeInstanceOf(HttpError);
275+
expect(createHttpError(HttpStatusCodes.INTERNAL_SERVER_ERROR)).toBeInstanceOf(InternalServerErrorException);
276+
expect(createHttpError(HttpStatusCodes.BAD_GATEWAY)).toBeInstanceOf(BadGatewayException);
277+
expect(createHttpError(HttpStatusCodes.TOO_MANY_REQUESTS)).toBeInstanceOf(TooManyRequestsException);
278+
expect(createHttpError(HttpStatusCodes.SERVICE_UNAVAILABLE)).toBeInstanceOf(ServiceUnavailableException);
279+
expect(createHttpError(HttpStatusCodes.GATEWAY_TIMEOUT)).toBeInstanceOf(GatewayTimeoutException);
280+
expect(createHttpError(HttpStatusCodes.INSUFFICIENT_STORAGE)).toBeInstanceOf(InsufficientStorageException);
270281
});
271282
it('sets message if provided', () => {
272283
const e = createHttpError(HttpStatusCodes.BAD_REQUEST, 'bad!');

0 commit comments

Comments
 (0)