Skip to content

Commit ce3d047

Browse files
authored
Merge pull request #23 from storyofams/refactor/pipes
[refactor] pipes
2 parents 574f515 + e0e089e commit ce3d047

File tree

12 files changed

+180
-54
lines changed

12 files changed

+180
-54
lines changed

lib/decorators/parameter.decorator.spec.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('Parameter decorators', () => {
1313
const meta = Reflect.getMetadata(PARAMETER_TOKEN, Test, 'index');
1414
expect(Array.isArray(meta)).toBe(true);
1515
expect(meta).toHaveLength(1);
16-
expect(meta).toMatchObject(expect.arrayContaining([{ index: 0, location: 'body' }]));
16+
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'body' })]));
1717
});
1818

1919
it('Header should be set.', () => {
@@ -26,13 +26,26 @@ describe('Parameter decorators', () => {
2626
expect(meta).toHaveLength(2);
2727
expect(meta).toMatchObject(
2828
expect.arrayContaining([
29-
{ index: 0, location: 'header', name: 'Content-Type' },
30-
{ index: 1, location: 'header', name: 'Referer' }
29+
expect.objectContaining({ index: 0, location: 'header', name: 'Content-Type' }),
30+
expect.objectContaining({ index: 1, location: 'header', name: 'Referer' })
3131
])
3232
);
3333
});
3434

35-
it('Header should be set.', () => {
35+
it('Query should be set for the whole query string.', () => {
36+
class Test {
37+
public index(@Query() query: any) {}
38+
}
39+
40+
const meta = Reflect.getMetadata(PARAMETER_TOKEN, Test, 'index');
41+
expect(Array.isArray(meta)).toBe(true);
42+
expect(meta).toHaveLength(1);
43+
expect(meta).toMatchObject(
44+
expect.arrayContaining([expect.objectContaining({ index: 0, location: 'query', name: undefined })])
45+
);
46+
});
47+
48+
it('Query parameters should be set.', () => {
3649
class Test {
3750
public index(
3851
@Query('firstName') firstName: string,
@@ -46,9 +59,9 @@ describe('Parameter decorators', () => {
4659
expect(meta).toHaveLength(3);
4760
expect(meta).toMatchObject(
4861
expect.arrayContaining([
49-
{ index: 0, location: 'query', name: 'firstName' },
50-
{ index: 1, location: 'query', name: 'lastName' },
51-
{ index: 2, location: 'query', name: 'city' }
62+
expect.objectContaining({ index: 0, location: 'query', name: 'firstName' }),
63+
expect.objectContaining({ index: 1, location: 'query', name: 'lastName' }),
64+
expect.objectContaining({ index: 2, location: 'query', name: 'city' })
5265
])
5366
);
5467
});
@@ -61,7 +74,7 @@ describe('Parameter decorators', () => {
6174
const meta = Reflect.getMetadata(PARAMETER_TOKEN, Test, 'index');
6275
expect(Array.isArray(meta)).toBe(true);
6376
expect(meta).toHaveLength(1);
64-
expect(meta).toMatchObject(expect.arrayContaining([{ index: 0, location: 'request' }]));
77+
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'request' })]));
6578
});
6679

6780
it('Res should be set.', () => {
@@ -72,7 +85,7 @@ describe('Parameter decorators', () => {
7285
const meta = Reflect.getMetadata(PARAMETER_TOKEN, Test, 'index');
7386
expect(Array.isArray(meta)).toBe(true);
7487
expect(meta).toHaveLength(1);
75-
expect(meta).toMatchObject(expect.arrayContaining([{ index: 0, location: 'response' }]));
88+
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'response' })]));
7689
});
7790

7891
it('Request and Response should be set.', () => {
@@ -85,8 +98,8 @@ describe('Parameter decorators', () => {
8598
expect(meta).toHaveLength(2);
8699
expect(meta).toMatchObject(
87100
expect.arrayContaining([
88-
{ index: 0, location: 'request' },
89-
{ index: 1, location: 'response' }
101+
expect.objectContaining({ index: 0, location: 'request' }),
102+
expect.objectContaining({ index: 1, location: 'response' })
90103
])
91104
);
92105
});

lib/decorators/parameter.decorators.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,33 @@ function addParameter(location: MetaParameter['location'], name?: MetaParameter[
1919
};
2020
}
2121

22+
/** Returns the query string. */
23+
export function Query(): ParameterDecorator;
2224
/**
2325
* Returns a parameter from the query string.
2426
*
2527
* @param name Parameter name
2628
*/
27-
export function Query(name: string, ...pipes: ParameterPipe<any>[]): ParameterDecorator {
28-
return addParameter('query', name, pipes.length ? pipes : undefined);
29+
export function Query(name: string, ...pipes: ParameterPipe<any>[]): ParameterDecorator;
30+
/**
31+
* Returns the query string with pipes applied.
32+
*
33+
* @param pipes Pipes to be applied.
34+
*/
35+
export function Query(...pipes: ParameterPipe<any>[]): ParameterDecorator;
36+
export function Query(nameOrPipes?: string | ParameterPipe<any>, ...pipes: ParameterPipe<any>[]): ParameterDecorator {
37+
if (typeof nameOrPipes === 'string') {
38+
return addParameter('query', nameOrPipes, pipes.length ? pipes : undefined);
39+
} else if (typeof nameOrPipes === 'function') {
40+
return addParameter('query', undefined, [nameOrPipes, ...pipes]);
41+
} else {
42+
return addParameter('query', undefined);
43+
}
2944
}
3045

3146
/** Returns the request body. */
32-
export function Body(): ParameterDecorator {
33-
return addParameter('body');
47+
export function Body(...pipes: ParameterPipe<any>[]): ParameterDecorator {
48+
return addParameter('body', undefined, pipes);
3449
}
3550

3651
/**

lib/e2e.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
55
import request from 'supertest';
66
import { createHandler } from './createHandler';
77
import { Body, Delete, Get, Header, HttpCode, Post, Put, Query, Req, Res, Response, SetHeader } from './decorators';
8+
import { ValidationPipe } from './pipes';
89
import { ParseBooleanPipe } from './pipes/parseBoolean.pipe';
910
import { ParseNumberPipe } from './pipes/parseNumber.pipe';
1011

@@ -56,7 +57,7 @@ class TestHandler {
5657
@HttpCode(201)
5758
@Post()
5859
@SetHeader('X-Method', 'create')
59-
public create(@Header('Content-Type') contentType: string, @Body() body: CreateDto) {
60+
public create(@Header('Content-Type') contentType: string, @Body(ValidationPipe) body: CreateDto) {
6061
return { contentType, receivedBody: body, test: this.testField, instanceOf: body instanceof CreateDto };
6162
}
6263

@@ -77,7 +78,7 @@ class TestHandler {
7778
const { 'content-type': contentType } = headers;
7879
const { id } = query;
7980

80-
res.status(200).json({ contentType, id, receivedBody: body, test: this.testField });
81+
return res.status(200).json({ contentType, id, receivedBody: body, test: this.testField });
8182
}
8283
}
8384

@@ -112,7 +113,7 @@ describe('E2E', () => {
112113
})
113114
));
114115

115-
it('read', () =>
116+
it('read without "step"', () =>
116117
request(server)
117118
.get('/?id=my-id&redirect=true')
118119
.set('Content-Type', 'application/json')
@@ -222,4 +223,16 @@ describe('E2E', () => {
222223
}
223224
})
224225
));
226+
227+
it('should throw express style 404 for an undefined http verb', () =>
228+
request(server)
229+
.patch('/')
230+
.set('Content-Type', 'application/json')
231+
.expect(404)
232+
.then(res =>
233+
expect(res.body).toMatchObject({
234+
statusCode: 404,
235+
error: 'Not Found'
236+
})
237+
));
225238
});

lib/internals/classValidator.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type { ClassConstructor } from 'class-transformer';
22
import { BadRequestException } from '../exceptions';
3+
import type { ValidationPipeOptions } from '../pipes';
34
import { flattenValidationErrors } from './getClassValidatorError';
45
import { loadPackage } from './loadPackage';
56

6-
export async function validateObject(cls: ClassConstructor<any>, value: Record<string, string>): Promise<any> {
7+
export async function validateObject(
8+
cls: ClassConstructor<any>,
9+
value: Record<string, string>,
10+
validatorOptions?: ValidationPipeOptions
11+
): Promise<any> {
712
const classValidator = loadPackage('class-validator');
813
if (!classValidator) {
914
return value;
@@ -15,10 +20,12 @@ export async function validateObject(cls: ClassConstructor<any>, value: Record<s
1520
}
1621

1722
const bodyValue = classTransformer.plainToClass(cls, value, {
18-
enableImplicitConversion: true
23+
enableImplicitConversion: true,
24+
...validatorOptions?.transformOptions
1925
});
2026
const validationErrors = await classValidator.validate(bodyValue, {
21-
enableDebugMessages: process.env.NODE_ENV === 'development'
27+
enableDebugMessages: process.env.NODE_ENV === 'development',
28+
...validatorOptions
2229
});
2330

2431
if (validationErrors.length) {

lib/internals/handler.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,18 @@ import type { ClassConstructor } from 'class-transformer';
44
import type { NextApiRequest, NextApiResponse } from 'next';
55
import { HEADER_TOKEN, HttpVerb, HTTP_CODE_TOKEN, MetaParameter, PARAMETER_TOKEN } from '../decorators';
66
import { HttpException } from '../exceptions';
7-
import { validateObject } from './classValidator';
87
import { notFound } from './notFound';
98

10-
async function getParameterValue(
9+
function getParameterValue(
1110
req: NextApiRequest,
1211
res: NextApiResponse,
13-
bodyParamType: ClassConstructor<any>[],
14-
{ location, name, index }: MetaParameter
15-
): Promise<string | object | undefined> {
12+
{ location, name }: MetaParameter
13+
): string | object | undefined {
1614
switch (location) {
1715
case 'query':
1816
return name ? req.query[name] : req.query;
19-
case 'body': {
20-
if (index < bodyParamType.length && typeof bodyParamType[index] === 'function') {
21-
return validateObject(bodyParamType[index], req.body);
22-
}
23-
17+
case 'body':
2418
return req.body;
25-
}
2619
case 'header':
2720
return name ? req.headers[name.toLowerCase()] : req.headers;
2821
case 'method':
@@ -58,23 +51,24 @@ export function Handler(method?: HttpVerb): MethodDecorator {
5851
target.constructor,
5952
propertyKey
6053
);
61-
const bodyParamType: ClassConstructor<any>[] =
62-
Reflect.getMetadata('design:paramtypes', target, propertyKey) ?? [];
54+
const paramTypes: ClassConstructor<any>[] = Reflect.getMetadata('design:paramtypes', target, propertyKey) ?? [];
6355

6456
try {
6557
const parameters = await Promise.all(
6658
metaParameters.map(async ({ location, name, pipes, index }) => {
67-
let returnValue = await getParameterValue(req, res, bodyParamType, { location, name, index });
59+
const paramType =
60+
index < paramTypes.length && typeof paramTypes[index] === 'function' ? paramTypes[index] : undefined;
61+
62+
let returnValue = getParameterValue(req, res, { location, name, index });
6863

6964
if (pipes && pipes.length) {
70-
pipes.forEach(
71-
pipeFn =>
72-
(returnValue = pipeFn.name
73-
? // Bare pipe function. i.e: `ParseNumberPipe`
74-
(pipeFn as Function).call(null, null).call(null, returnValue, name)
75-
: // Pipe with options. i.e: `ParseNumberPipe({ nullable: false })`
76-
pipeFn.call(null, returnValue, name))
77-
);
65+
for (const pipeFn of pipes) {
66+
returnValue = pipeFn.name
67+
? // Bare pipe function. i.e: `ParseNumberPipe`
68+
await pipeFn.call(null, null).call(null, returnValue, { name, metaType: paramType })
69+
: // Pipe with options. i.e: `ParseNumberPipe({ nullable: false })`
70+
await pipeFn.call(null, returnValue, { name, metaType: paramType });
71+
}
7872
}
7973

8074
return returnValue;
@@ -86,7 +80,7 @@ export function Handler(method?: HttpVerb): MethodDecorator {
8680

8781
const returnValue = await originalHandler.call(this, ...parameters);
8882

89-
if (returnValue instanceof ServerResponse || res.headersSent) {
83+
if (returnValue instanceof ServerResponse || res.writableEnded || res.finished) {
9084
return;
9185
}
9286

lib/pipes/ParameterPipe.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
export interface PipeMetadata<T = any> {
2+
readonly metaType?: T;
3+
readonly name?: string;
4+
}
5+
16
export interface PipeOptions {
2-
nullable?: boolean;
7+
readonly nullable?: boolean;
38
}
49

5-
export type ParameterPipe<T> = (value: any, name?: string) => T;
10+
export type ParameterPipe<TOutput, TMeta = unknown> = (value: any, metadata?: PipeMetadata<TMeta>) => TOutput;

lib/pipes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './parseBoolean.pipe';
22
export * from './parseNumber.pipe';
3+
export * from './validation.pipe';
4+
export * from './validateEnum.pipe';

lib/pipes/parseBoolean.pipe.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { BadRequestException } from '../exceptions';
2-
import type { ParameterPipe, PipeOptions } from './ParameterPipe';
2+
import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe';
33
import { validatePipeOptions } from './validatePipeOptions';
44

55
export function ParseBooleanPipe(options?: PipeOptions): ParameterPipe<boolean> {
6-
return (value: any, name?: string) => {
7-
validatePipeOptions(value, name, options);
6+
return (value: any, metadata?: PipeMetadata) => {
7+
validatePipeOptions(value, metadata?.name, options);
88

99
if (value === true || value === 'true') {
1010
return true;
@@ -14,6 +14,8 @@ export function ParseBooleanPipe(options?: PipeOptions): ParameterPipe<boolean>
1414
return false;
1515
}
1616

17-
throw new BadRequestException(`Validation failed${name ? ` for ${name}` : ''} (boolean string is expected)`);
17+
throw new BadRequestException(
18+
`Validation failed${metadata?.name ? ` for ${metadata.name}` : ''} (boolean string is expected)`
19+
);
1820
};
1921
}

lib/pipes/parseNumber.pipe.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { BadRequestException } from '../exceptions';
2-
import type { ParameterPipe, PipeOptions } from './ParameterPipe';
2+
import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe';
33
import { validatePipeOptions } from './validatePipeOptions';
44

55
export function ParseNumberPipe(options?: PipeOptions): ParameterPipe<number> {
6-
return (value: any, name?: string) => {
7-
validatePipeOptions(value, name, options);
6+
return (value: any, metadata?: PipeMetadata) => {
7+
validatePipeOptions(value, metadata?.name, options);
88

99
const isNumeric = ['string', 'number'].includes(typeof value) && !isNaN(parseFloat(value)) && isFinite(value);
1010
if (!isNumeric) {
11-
throw new BadRequestException(`Validation failed${name ? ` for ${name}` : ''} (numeric string is expected)`);
11+
throw new BadRequestException(
12+
`Validation failed${metadata?.name ? ` for ${metadata.name}` : ''} (numeric string is expected)`
13+
);
1214
}
1315

1416
return parseFloat(value);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ValidateEnumPipe } from './validateEnum.pipe';
2+
3+
enum UserStatus {
4+
ACTIVE = 'active',
5+
INACTIVE = 'inactive',
6+
BANNED = 'banned',
7+
DELETED = 'deleted'
8+
}
9+
10+
describe('ValidateEnumPipe', () => {
11+
it('Should pass with a correct value', () =>
12+
expect(ValidateEnumPipe({ type: UserStatus })('banned', { name: 'status' })).toStrictEqual(UserStatus.BANNED));
13+
14+
it('Should throw when the given value is an invalid enum value', () =>
15+
expect(() => ValidateEnumPipe({ type: UserStatus })('test', { name: 'status' })).toThrow());
16+
17+
it('Should throw when pipe is non-nullable and the given value is undefined.', () =>
18+
expect(() => ValidateEnumPipe({ type: UserStatus, nullable: false })(undefined, { name: 'status' })).toThrow());
19+
20+
it('Should pass when pipe is non-nullable and the given value is undefined.', () =>
21+
expect(ValidateEnumPipe({ type: UserStatus, nullable: true })(undefined, { name: 'status' })).toStrictEqual(
22+
undefined
23+
));
24+
25+
it('Should pass when the given value is invalid but there is no type', () =>
26+
expect(ValidateEnumPipe()('unknown', { name: 'status' })).toStrictEqual('unknown'));
27+
});

0 commit comments

Comments
 (0)