Skip to content

Commit 0f12eaa

Browse files
authored
Merge pull request #26 from storyofams/beta
Release v1.2.0
2 parents b1d7137 + e8469df commit 0f12eaa

19 files changed

+357
-65
lines changed

.github/workflows/release.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ on:
33
push:
44
branches:
55
- master
6+
- beta
67
jobs:
78
release:
89
name: Release

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: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import express from 'express';
44
import type { NextApiRequest, NextApiResponse } from 'next';
55
import request from 'supertest';
66
import { createHandler } from './createHandler';
7-
import { Body, Delete, Get, Header, HttpCode, Post, Put, Query, Req, Response, SetHeader } from './decorators';
7+
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';
10+
import { ParseDatePipe } from './pipes/parseDate.pipe';
911
import { ParseNumberPipe } from './pipes/parseNumber.pipe';
1012

1113
enum CreateSource {
@@ -47,23 +49,36 @@ class TestHandler {
4749
public read(
4850
@Header('Content-Type') contentType: string,
4951
@Query('id') id: string,
50-
@Query('step', ParseNumberPipe) step: number,
51-
@Query('redirect', ParseBooleanPipe) redirect: boolean
52+
@Query('step', ParseNumberPipe({ nullable: false })) step: number,
53+
@Query('redirect', ParseBooleanPipe) redirect: boolean,
54+
@Query('startAt', ParseDatePipe) startAt: Date
5255
) {
53-
return { contentType, id, step, redirect, test: this.testField };
56+
return {
57+
contentType,
58+
id,
59+
step,
60+
redirect,
61+
test: this.testField,
62+
startAt,
63+
isStartAtDateInstance: startAt instanceof Date
64+
};
5465
}
5566

5667
@HttpCode(201)
5768
@Post()
5869
@SetHeader('X-Method', 'create')
59-
public create(@Header('Content-Type') contentType: string, @Body() body: CreateDto) {
70+
public create(@Header('Content-Type') contentType: string, @Body(ValidationPipe) body: CreateDto) {
6071
return { contentType, receivedBody: body, test: this.testField, instanceOf: body instanceof CreateDto };
6172
}
6273

6374
@Put()
6475
@SetHeader('X-Method', 'update')
65-
public update(@Header('Content-Type') contentType: string, @Query('id') id: string, @Body() body: any) {
66-
return { contentType, id, receivedBody: body, test: this.testField };
76+
public update(@Req() req: NextApiRequest, @Res() res: NextApiResponse) {
77+
const { headers, query, body } = req;
78+
const { 'content-type': contentType } = headers;
79+
const { id } = query;
80+
81+
res.status(200).json({ contentType, id, receivedBody: body, test: this.testField });
6782
}
6883

6984
@Delete()
@@ -89,7 +104,7 @@ describe('E2E', () => {
89104

90105
it('read', () =>
91106
request(server)
92-
.get('/?id=my-id&step=1&redirect=true')
107+
.get('/?id=my-id&step=1&redirect=true&startAt=2021-01-01T22:00:00')
93108
.set('Content-Type', 'application/json')
94109
.expect(200)
95110
.then(res =>
@@ -103,7 +118,21 @@ describe('E2E', () => {
103118
contentType: 'application/json',
104119
id: 'my-id',
105120
step: 1,
106-
redirect: true
121+
redirect: true,
122+
isStartAtDateInstance: true
123+
}
124+
})
125+
));
126+
127+
it('read without "step"', () =>
128+
request(server)
129+
.get('/?id=my-id&redirect=true')
130+
.set('Content-Type', 'application/json')
131+
.expect(400)
132+
.then(res =>
133+
expect(res).toMatchObject({
134+
body: {
135+
message: 'step is a required parameter.'
107136
}
108137
})
109138
));
@@ -205,4 +234,16 @@ describe('E2E', () => {
205234
}
206235
})
207236
));
237+
238+
it('should throw express style 404 for an undefined http verb', () =>
239+
request(server)
240+
.patch('/')
241+
.set('Content-Type', 'application/json')
242+
.expect(404)
243+
.then(res =>
244+
expect(res.body).toMatchObject({
245+
statusCode: 404,
246+
error: 'Not Found'
247+
})
248+
));
208249
});

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: 18 additions & 17 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,16 +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

69-
if (returnValue && pipes && pipes.length) {
70-
pipes.forEach(pipeFn => (returnValue = pipeFn(returnValue)));
64+
if (pipes && pipes.length) {
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+
}
7172
}
7273

7374
return returnValue;
@@ -79,7 +80,7 @@ export function Handler(method?: HttpVerb): MethodDecorator {
7980

8081
const returnValue = await originalHandler.call(this, ...parameters);
8182

82-
if (returnValue instanceof ServerResponse || res.headersSent) {
83+
if (returnValue instanceof ServerResponse || res.writableEnded || res.finished) {
8384
return;
8485
}
8586

lib/pipes/ParameterPipe.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
export type ParameterPipe<T> = (value: any) => T;
1+
export interface PipeMetadata<T = any> {
2+
readonly metaType?: T;
3+
readonly name?: string;
4+
}
5+
6+
export interface PipeOptions {
7+
readonly nullable?: boolean;
8+
}
9+
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';
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { ParseBooleanPipe } from './parseBoolean.pipe';
22

33
describe('ParseBooleanPipe', () => {
4-
it('Should parse the given string as boolean (true)', () => expect(ParseBooleanPipe('true')).toStrictEqual(true));
4+
it('Should parse the given string as boolean (true)', () => expect(ParseBooleanPipe()('true')).toStrictEqual(true));
55

6-
it('Should parse the given string as boolean (false)', () => expect(ParseBooleanPipe('false')).toStrictEqual(false));
6+
it('Should parse the given string as boolean (false)', () =>
7+
expect(ParseBooleanPipe()('false')).toStrictEqual(false));
8+
9+
it('Should throw required error the given value is empty', () =>
10+
expect(() => ParseBooleanPipe({ nullable: false })('')).toThrow());
711

812
it('Should throw when the given string is not a boolean string', () =>
9-
expect(() => ParseBooleanPipe('test')).toThrow());
13+
expect(() => ParseBooleanPipe()('test')).toThrow());
1014
});

lib/pipes/parseBoolean.pipe.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { BadRequestException } from '../exceptions';
2+
import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe';
3+
import { validatePipeOptions } from './validatePipeOptions';
24

3-
export function ParseBooleanPipe(value: any): boolean {
4-
if (value === true || value === 'true') {
5-
return true;
6-
}
5+
export function ParseBooleanPipe(options?: PipeOptions): ParameterPipe<boolean> {
6+
return (value: any, metadata?: PipeMetadata) => {
7+
validatePipeOptions(value, metadata?.name, options);
78

8-
if (value === false || value === 'false') {
9-
return false;
10-
}
9+
if (value === true || value === 'true') {
10+
return true;
11+
}
1112

12-
throw new BadRequestException('Validation failed (boolean string is expected)');
13+
if (value === false || value === 'false') {
14+
return false;
15+
}
16+
17+
throw new BadRequestException(
18+
`Validation failed${metadata?.name ? ` for ${metadata.name}` : ''} (boolean string is expected)`
19+
);
20+
};
1321
}

0 commit comments

Comments
 (0)