Skip to content

Commit 260ad2a

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

3 files changed

Lines changed: 177 additions & 5 deletions

File tree

docs/docs/utils/decorators.md

Lines changed: 51 additions & 0 deletions
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.
@@ -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: 50 additions & 4 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 */
@@ -552,6 +552,18 @@ function createParamDecorator(type: ParamDefinition['type'], key?: string) {
552552
};
553553
}
554554

555+
function createParamDecoratorWithoutParam(type: ParamDefinition['type']) {
556+
return (): ParameterDecorator => {
557+
return (target, propertyKey, parameterIndex) => {
558+
const params: ParamDefinition[] = Reflect.getMetadata(PARAMS_KEY, target as object, propertyKey as string) || [];
559+
// Ensure parameters are ordered by index
560+
params.push({ index: parameterIndex, type, key: undefined });
561+
params.sort((a, b) => a.index - b.index);
562+
Reflect.defineMetadata(PARAMS_KEY, params, target as object, propertyKey as string);
563+
};
564+
};
565+
}
566+
555567
/**
556568
* Factory function that creates parameter decorators.
557569
*
@@ -652,7 +664,20 @@ export const Body = createParamDecorator('body');
652664
* }
653665
* ```
654666
*/
655-
export const ReqLogger = createParamDecorator('logger');
667+
export const ReqLogger = createParamDecoratorWithoutParam('logger');
668+
669+
/**
670+
* Decorator that extracts request ID from headers.
671+
* @returns Parameter decorator
672+
* @example
673+
* ```ts
674+
* @Get('/data')
675+
* getData(@ReqId() reqId: string) {
676+
* // reqId will contain the value of req.headers['x-request-id'] or req.id
677+
* }
678+
* ```
679+
*/
680+
export const ReqId = createParamDecoratorWithoutParam('reqId');
656681

657682
/**
658683
* Decorator that extracts request headers.
@@ -669,6 +694,21 @@ export const ReqLogger = createParamDecorator('logger');
669694
*/
670695
export const ReqHeader = createParamDecorator('reqHeader');
671696

697+
/**
698+
* Decorator that extracts cookies from request.
699+
* @param key - Optional key to extract specific cookie
700+
* @returns Parameter decorator
701+
*
702+
* @example
703+
* ```ts
704+
* @Get('/data')
705+
* getData(@ReqCookie('session_id') sessionId: string) {
706+
* // sessionId will contain the value of req.cookies['session_id']
707+
* }
708+
* ```
709+
*/
710+
export const ReqCookie = createParamDecorator('cookie');
711+
672712
/**
673713
* Decorator that injects the entire request object.
674714
*
@@ -683,7 +723,7 @@ export const ReqHeader = createParamDecorator('reqHeader');
683723
* }
684724
* ```
685725
*/
686-
export const Req = createParamDecorator('req');
726+
export const Req = createParamDecoratorWithoutParam('req');
687727

688728
/**
689729
* Decorator that injects the response object.
@@ -699,7 +739,7 @@ export const Req = createParamDecorator('req');
699739
* }
700740
* ```
701741
*/
702-
export const Res = createParamDecorator('res');
742+
export const Res = createParamDecoratorWithoutParam('res');
703743

704744
/**
705745
* Decorator that sets a custom HTTP status code for a response.
@@ -1387,6 +1427,12 @@ export function registerControllers(router: Router, controllers: any[]) {
13871427
case 'reqHeader':
13881428
args[index] = key ? req.headers[key.toLowerCase()] : req.headers;
13891429
break;
1430+
case 'reqId':
1431+
args[index] = req.headers['x-request-id'] || req?.id || undefined;
1432+
break;
1433+
case 'cookie':
1434+
args[index] = key ? req.cookies?.[key] : req.cookies;
1435+
break;
13901436
}
13911437
});
13921438
}

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 {

0 commit comments

Comments
 (0)