diff --git a/.gitignore b/.gitignore index bcd4c1b1..594fdb23 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,3 @@ npm-debug.log* yarn-debug.log* yarn-error.log* - -# storybook -storybook-static diff --git a/README.md b/README.md index f4f0f6b9..8f5297f0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,14 @@ --- -Collection of decorators to create typed Next.js API routes, with easy request validation and transformation. +This package contains a collection of decorators to create typed Next.js API routes, with easy request validation and transformation. + +## Motivation + +Building serverless functions declaratively with classes and decorators makes dealing with Next.js API routes easier and brings order and sanity to your `/pages/api` codebase. + +The structure is heavily inspired by NestJS, which is an amazing framework for a lot of use cases. On the other hand, a separate NestJS repo for your backend can also bring unneeded overhead and complexity to projects with a smaller set of backend requirements. Combining the structure of NestJS, with the ease of use of Next.js, brings the best of both worlds for the right use case. + ## Installation @@ -34,29 +41,35 @@ $ yarn add @storyofams/next-api-decorators Since decorators are still in proposal state, you need to add the following plugins to your `devDependencies` in order to use them: ```bash -$ yarn add -D babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators babel-plugin-parameter-decorator +$ yarn add -D @babel/core babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators babel-plugin-parameter-decorator ``` -Make sure to add the following lines to the `plugins` section in your babel configuration file: -```json -"babel-plugin-transform-typescript-metadata", -["@babel/plugin-proposal-decorators", { "legacy": true }], -"babel-plugin-parameter-decorator", +Make sure to add the following lines to the start of the `plugins` section in your babel configuration file: +```json5 +{ + "plugins": [ + "babel-plugin-transform-typescript-metadata", + ["@babel/plugin-proposal-decorators", { "legacy": true }], + "babel-plugin-parameter-decorator", + // ... other plugins + ] +} ``` Your `tsconfig.json` needs the following flags: -```json +```json5 "experimentalDecorators": true ``` + ## Usage ### Basic example ```ts // pages/api/user.ts -import { createHandler, Get, Post, Query, Body, NotFoundException } from '@storyofams/next-api-decorators'; +import { createHandler, Get, Query, NotFoundException } from '@storyofams/next-api-decorators'; class User { // GET /api/user @@ -70,12 +83,6 @@ class User { return user; } - - // POST /api/user - @Post() - public createUser(@Body() body: any) { - return DB.createUser(body); - } } export default createHandler(User); @@ -92,7 +99,8 @@ $ yarn add class-validator class-transformer Then you can define your DTOs like: ```ts -import { createHandler, Post, Body } from '@storyofams/next-api-decorators'; +// pages/api/user.ts +import { createHandler, Post, HttpCode, Body } from '@storyofams/next-api-decorators'; import { IsNotEmpty, IsEmail } from 'class-validator'; class CreateUserDto { @@ -104,7 +112,9 @@ class CreateUserDto { } class User { + // POST /api/user @Post() + @HttpCode(201) public createUser(@Body() body: CreateUserDto) { return User.create(body); } @@ -113,6 +123,7 @@ class User { export default createHandler(User); ``` + ## Available decorators ### Class decorators @@ -136,12 +147,13 @@ export default createHandler(User); | | Description | | ----------------------- | ------------------------------------------- | +| `@Req()` | Gets the request object. | +| `@Res()`* | Gets the response object. | | `@Body()` | Gets the request body. | | `@Query(key: string)` | Gets a query string parameter value by key. | | `@Header(name: string)` | Gets a header value by name. | - - +\* Note that when you inject `@Res()` in a method handler you become responsible for managing the response. When doing so, you must issue some kind of response by making a call on the response object (e.g., `res.json(...)` or `res.send(...)`), or the HTTP server will hang. ## Built-in pipes @@ -153,19 +165,24 @@ Pipes are being used to validate and transform incoming values. The pipes can be ⚠️ Beware that they throw when the value is invalid. -| | Description | Remarks | -| ------------------ | ------------------------------------------- | --------------------------------------------- | -| `ParseNumberPipe` | Validates and transforms `Number` strings. | Uses `parseFloat` under the hood | -| `ParseBooleanPipe` | Validates and transforms `Boolean` strings. | Allows `'true'` and `'false'` as valid values | - +| | Description | Remarks | +| ------------------ | ------------------------------------------- | -------------------------------------------------- | +| `ParseBooleanPipe` | Validates and transforms `Boolean` strings. | Allows `'true'` and `'false'` as valid values. | +| `ParseDatePipe` | Validates and transforms `Date` strings. | Allows valid `ISO 8601` formatted date strings. | +| `ParseNumberPipe` | Validates and transforms `Number` strings. | Uses `parseFloat` under the hood. | +| `ValidateEnumPipe` | Validates string based on `Enum` values. | Allows strings that are present in the given enum. | ## Exceptions -The following built-in exceptions are provided by this package: - -* `NotFoundException` -* `BadRequestException` +The following common exceptions are provided by this package. +| | Status code | Default message | +| ------------------------------ | ----------- | ------------------------- | +| `BadRequestException` | `400` | `'Bad Request'` | +| `UnauthorizedException` | `401` | `'Unauthorized'` | +| `NotFoundException` | `404` | `'Not Found'` | +| `UnprocessableEntityException` | `422` | `'Unprocessable Entity'` | +| `InternalServerErrorException` | `500` | `'Internal Server Error'` | ### Custom exceptions @@ -175,7 +192,7 @@ Any exception class that extends the base `HttpException` will be handled by the import { HttpException } from '@storyofams/next-api-decorators'; export class ForbiddenException extends HttpException { - public constructor(message?: string) { + public constructor(message?: string = 'Forbidden') { super(403, message); } } diff --git a/jest.config.json b/jest.config.json index 76e66a05..d3c88ff8 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,10 +1,10 @@ { "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": "lib", + "rootDir": ".", "testRegex": ".(spec|test).ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "coverageDirectory": "../coverage", + "coverageDirectory": "coverage", "testEnvironment": "node" } diff --git a/lib/decorators/.eslintrc b/lib/decorators/.eslintrc index 3935b0ac..b8a5800c 100644 --- a/lib/decorators/.eslintrc +++ b/lib/decorators/.eslintrc @@ -2,4 +2,4 @@ "rules": { "@typescript-eslint/ban-types": "off" } -} \ No newline at end of file +} diff --git a/lib/decorators/httpCode.decorator.spec.ts b/lib/decorators/httpCode.decorator.spec.ts index 8a13358c..b45e1490 100644 --- a/lib/decorators/httpCode.decorator.spec.ts +++ b/lib/decorators/httpCode.decorator.spec.ts @@ -7,5 +7,5 @@ class Test { public create(): void {} } -it('HttpCode decorator should be set.', () => +it('Should set the HttpCode decorator.', () => expect(Reflect.getMetadata(HTTP_CODE_TOKEN, Test, 'create')).toStrictEqual(201)); diff --git a/lib/decorators/httpMethod.decorator.spec.ts b/lib/decorators/httpMethod.decorators.spec.ts similarity index 94% rename from lib/decorators/httpMethod.decorator.spec.ts rename to lib/decorators/httpMethod.decorators.spec.ts index 04f2f8cc..b9eb3657 100644 --- a/lib/decorators/httpMethod.decorator.spec.ts +++ b/lib/decorators/httpMethod.decorators.spec.ts @@ -19,7 +19,7 @@ class Test { public delete(): void {} } -it('HttpMethod decorator should be set.', () => { +it('Should set the HttpMethod decorator.', () => { const meta = Reflect.getMetadata(HTTP_METHOD_TOKEN, Test); expect(meta).toBeInstanceOf(Map); expect(meta).toMatchObject( diff --git a/lib/decorators/httpMethod.decorators.ts b/lib/decorators/httpMethod.decorators.ts index 872f32da..3f3fc334 100644 --- a/lib/decorators/httpMethod.decorators.ts +++ b/lib/decorators/httpMethod.decorators.ts @@ -18,7 +18,7 @@ function applyHttpMethod(verb: HttpVerb) { Reflect.defineMetadata(HTTP_METHOD_TOKEN, methods, target.constructor); - Handler(verb)(target, propertyKey, descriptor); + Handler()(target, propertyKey, descriptor); }; } diff --git a/lib/decorators/parameter.decorator.spec.ts b/lib/decorators/parameter.decorators.spec.ts similarity index 89% rename from lib/decorators/parameter.decorator.spec.ts rename to lib/decorators/parameter.decorators.spec.ts index e816cc24..172dc072 100644 --- a/lib/decorators/parameter.decorator.spec.ts +++ b/lib/decorators/parameter.decorators.spec.ts @@ -5,7 +5,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { Body, PARAMETER_TOKEN, Req, Request, Res, Response, Header, Query } from './parameter.decorators'; describe('Parameter decorators', () => { - it('Body should be set.', () => { + it('Should set the Body decorator.', () => { class Test { public index(@Body() body: any) {} } @@ -16,7 +16,7 @@ describe('Parameter decorators', () => { expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'body' })])); }); - it('Header should be set.', () => { + it('Should set the Header decorator for the given names.', () => { class Test { public index(@Header('Content-Type') contentType: string, @Header('Referer') referer: string): void {} } @@ -32,7 +32,7 @@ describe('Parameter decorators', () => { ); }); - it('Query should be set for the whole query string.', () => { + it('Should set the Query decorator for the whole query string.', () => { class Test { public index(@Query() query: any) {} } @@ -45,7 +45,7 @@ describe('Parameter decorators', () => { ); }); - it('Query parameters should be set.', () => { + it('Should set the Query decorator for the given keys.', () => { class Test { public index( @Query('firstName') firstName: string, @@ -66,7 +66,7 @@ describe('Parameter decorators', () => { ); }); - it('Req should be set.', () => { + it('Should set the Req decorator.', () => { class Test { public index(@Req() req: NextApiRequest) {} } @@ -77,7 +77,7 @@ describe('Parameter decorators', () => { expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'request' })])); }); - it('Res should be set.', () => { + it('Should set the Res decorator.', () => { class Test { public index(@Res() res: NextApiResponse) {} } @@ -88,7 +88,7 @@ describe('Parameter decorators', () => { expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'response' })])); }); - it('Request and Response should be set.', () => { + it('Should set the Request and Response decoractors (aliases).', () => { class Test { public index(@Request() req: NextApiRequest, @Response() res: NextApiResponse) {} } diff --git a/lib/decorators/setHeader.decorator.spec.ts b/lib/decorators/setHeader.decorator.spec.ts index 393f855f..7a7689bf 100644 --- a/lib/decorators/setHeader.decorator.spec.ts +++ b/lib/decorators/setHeader.decorator.spec.ts @@ -8,7 +8,7 @@ class Test { public index(): void {} } -it('SetHeader should be set.', () => { +it('Should set the SetHeader decorator for the given name.', () => { const meta = Reflect.getMetadata(HEADER_TOKEN, Test); const methodMeta = Reflect.getMetadata(HEADER_TOKEN, Test, 'index'); diff --git a/lib/exceptions/BadRequestException.ts b/lib/exceptions/BadRequestException.ts index fc9bd976..2c3a854b 100644 --- a/lib/exceptions/BadRequestException.ts +++ b/lib/exceptions/BadRequestException.ts @@ -3,7 +3,7 @@ import { HttpException } from './HttpException'; export class BadRequestException extends HttpException { public name = 'BadRequestException'; - public constructor(message?: string, errors?: string[]) { + public constructor(message: string = 'Bad Request', errors?: string[]) { super(400, message, errors); } } diff --git a/lib/exceptions/HttpException.spec.ts b/lib/exceptions/HttpException.spec.ts new file mode 100644 index 00000000..1b3666d4 --- /dev/null +++ b/lib/exceptions/HttpException.spec.ts @@ -0,0 +1,50 @@ +import { + HttpException, + BadRequestException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, + UnprocessableEntityException +} from '.'; + +describe('HttpException', () => { + it(`Should use 'HttpException' as name`, () => + expect(new HttpException(500)).toHaveProperty('name', 'HttpException')); + + it('Should set the given number as statusCode', () => + expect(new HttpException(500)).toHaveProperty('statusCode', 500)); + + it('Should set the given string as message', () => + expect(new HttpException(403, 'Forbidden')).toHaveProperty('message', 'Forbidden')); + + it('Should set the given array of strings as errors', () => + expect( + new HttpException(400, 'Bad request', ['First name is required', 'Last name is required']) + ).toHaveProperty('errors', ['First name is required', 'Last name is required'])); + + it('Should set the name, statusCode, message and errors', () => + expect(new HttpException(400, 'Bad request', ['Invalid email address'])).toMatchObject({ + name: 'HttpException', + statusCode: 400, + message: 'Bad request', + errors: ['Invalid email address'] + })); + + describe('Default errors', () => { + it('Should set the default status codes', () => { + expect(new BadRequestException()).toHaveProperty('statusCode', 400); + expect(new InternalServerErrorException()).toHaveProperty('statusCode', 500); + expect(new NotFoundException()).toHaveProperty('statusCode', 404); + expect(new UnauthorizedException()).toHaveProperty('statusCode', 401); + expect(new UnprocessableEntityException()).toHaveProperty('statusCode', 422); + }); + + it('Should set the default error messages', () => { + expect(new BadRequestException()).toHaveProperty('message', 'Bad Request'); + expect(new InternalServerErrorException()).toHaveProperty('message', 'Internal Server Error'); + expect(new NotFoundException()).toHaveProperty('message', 'Not Found'); + expect(new UnauthorizedException()).toHaveProperty('message', 'Unauthorized'); + expect(new UnprocessableEntityException()).toHaveProperty('message', 'Unprocessable Entity'); + }); + }); +}); diff --git a/lib/exceptions/InternalServerErrorException.ts b/lib/exceptions/InternalServerErrorException.ts new file mode 100644 index 00000000..7a380fdb --- /dev/null +++ b/lib/exceptions/InternalServerErrorException.ts @@ -0,0 +1,9 @@ +import { HttpException } from './HttpException'; + +export class InternalServerErrorException extends HttpException { + public name = 'InternalServerErrorException'; + + public constructor(message: string = 'Internal Server Error') { + super(500, message); + } +} diff --git a/lib/exceptions/NotFoundException.ts b/lib/exceptions/NotFoundException.ts index 489d5bc4..de2a8561 100644 --- a/lib/exceptions/NotFoundException.ts +++ b/lib/exceptions/NotFoundException.ts @@ -1,9 +1,9 @@ import { HttpException } from './HttpException'; export class NotFoundException extends HttpException { - public name = 'BadRequestException'; + public name = 'NotFoundException'; - public constructor(message?: string) { + public constructor(message: string = 'Not Found') { super(404, message); } } diff --git a/lib/exceptions/UnauthorizedException.ts b/lib/exceptions/UnauthorizedException.ts new file mode 100644 index 00000000..125b379f --- /dev/null +++ b/lib/exceptions/UnauthorizedException.ts @@ -0,0 +1,9 @@ +import { HttpException } from './HttpException'; + +export class UnauthorizedException extends HttpException { + public name = 'UnauthorizedException'; + + public constructor(message: string = 'Unauthorized') { + super(401, message); + } +} diff --git a/lib/exceptions/UnprocessableEntityException.ts b/lib/exceptions/UnprocessableEntityException.ts new file mode 100644 index 00000000..b5a9de84 --- /dev/null +++ b/lib/exceptions/UnprocessableEntityException.ts @@ -0,0 +1,9 @@ +import { HttpException } from './HttpException'; + +export class UnprocessableEntityException extends HttpException { + public name = 'UnprocessableEntityException'; + + public constructor(message: string = 'Unprocessable Entity', errors?: string[]) { + super(422, message, errors); + } +} diff --git a/lib/exceptions/index.ts b/lib/exceptions/index.ts index 593d67f3..1e907d67 100644 --- a/lib/exceptions/index.ts +++ b/lib/exceptions/index.ts @@ -1,3 +1,6 @@ export * from './HttpException'; -export * from './NotFoundException'; export * from './BadRequestException'; +export * from './InternalServerErrorException'; +export * from './NotFoundException'; +export * from './UnauthorizedException'; +export * from './UnprocessableEntityException'; diff --git a/lib/internals/classValidator.spec.ts b/lib/internals/classValidator.spec.ts new file mode 100644 index 00000000..6befc251 --- /dev/null +++ b/lib/internals/classValidator.spec.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata'; +import { Expose } from 'class-transformer'; +import { IsNotEmpty } from 'class-validator'; +import { validateObject } from './classValidator'; +import * as lp from './loadPackage'; + +describe('validateObject', () => { + it('Should return the value if "class-validator" is not being used.', async () => { + const spy = jest + .spyOn(lp, 'loadPackage') + .mockImplementation((name: string) => (name === 'class-validator' ? false : require(name))); + + class Dto { + @IsNotEmpty() + public email!: string; + } + + const result = await validateObject(Dto, { secondaryEmail: 'dev@storyofams.com' }); + + expect(result).toHaveProperty('secondaryEmail', 'dev@storyofams.com'); + + spy.mockRestore(); + }); + + it('Should return the value if "class-transformer" is not being used.', async () => { + const spy = jest + .spyOn(lp, 'loadPackage') + .mockImplementation((name: string) => (name === 'class-transformer' ? false : require(name))); + + class Dto { + @IsNotEmpty() + public email!: string; + } + + const result = await validateObject(Dto, { secondaryEmail: 'dev@storyofams.com' }); + + expect(result).toHaveProperty('secondaryEmail', 'dev@storyofams.com'); + + spy.mockRestore(); + }); + + it('Should return only exposed properties.', async () => { + class Dto { + @Expose() + @IsNotEmpty() + public email!: string; + } + + const result = await validateObject( + Dto, + { email: 'dev@storyofams.com', secondaryEmail: 'hello@storyofams.com' }, + { + transformOptions: { excludeExtraneousValues: true } + } + ); + + expect(result).toHaveProperty('email', 'dev@storyofams.com'); + expect(result).not.toHaveProperty('secondaryEmail', 'hello@storyofams.com'); + }); +}); diff --git a/lib/internals/handler.ts b/lib/internals/handler.ts index 6695c205..10c4acc0 100644 --- a/lib/internals/handler.ts +++ b/lib/internals/handler.ts @@ -18,8 +18,6 @@ function getParameterValue( return req.body; case 'header': return name ? req.headers[name.toLowerCase()] : req.headers; - case 'method': - return req.method; case 'request': return req; case 'response': @@ -29,18 +27,10 @@ function getParameterValue( } } -export function Handler(method?: HttpVerb): MethodDecorator { - if (!method) { - method = HttpVerb.GET; - } - +export function Handler(): MethodDecorator { return function (target: object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor) { const originalHandler = descriptor.value; descriptor.value = async function (req: NextApiRequest, res: NextApiResponse) { - if (req.method !== method) { - return notFound(req, res); - } - const httpCode: number | undefined = Reflect.getMetadata(HTTP_CODE_TOKEN, target.constructor, propertyKey); const metaParameters: Array = ( Reflect.getMetadata(PARAMETER_TOKEN, target.constructor, propertyKey) ?? [] diff --git a/lib/internals/loadPackage.spec.ts b/lib/internals/loadPackage.spec.ts new file mode 100644 index 00000000..9b2bb0cb --- /dev/null +++ b/lib/internals/loadPackage.spec.ts @@ -0,0 +1,8 @@ +import { loadPackage } from './loadPackage'; + +describe('loadPackage', () => { + it('Should load a package correctly.', () => expect(loadPackage('class-validator')).not.toEqual(false)); + + it('Should return false for a non existing package.', () => + expect(loadPackage('a-package-that-does-not-exist')).toEqual(false)); +}); diff --git a/lib/pipes/ParameterPipe.ts b/lib/pipes/ParameterPipe.ts index 452eb6ed..b3e84c3c 100644 --- a/lib/pipes/ParameterPipe.ts +++ b/lib/pipes/ParameterPipe.ts @@ -7,4 +7,7 @@ export interface PipeOptions { readonly nullable?: boolean; } -export type ParameterPipe = (value: any, metadata?: PipeMetadata) => TOutput; +export type ParameterPipe = ( + value: any, + metadata?: PipeMetadata +) => TOutput | undefined; diff --git a/lib/pipes/index.ts b/lib/pipes/index.ts index 7e6d43d3..a5ad51fe 100644 --- a/lib/pipes/index.ts +++ b/lib/pipes/index.ts @@ -1,4 +1,2 @@ -export * from './parseBoolean.pipe'; -export * from './parseNumber.pipe'; -export * from './validation.pipe'; -export * from './validateEnum.pipe'; +export * from './validators'; +export * from './parsers'; diff --git a/lib/pipes/parsers/index.ts b/lib/pipes/parsers/index.ts new file mode 100644 index 00000000..59d2dbf7 --- /dev/null +++ b/lib/pipes/parsers/index.ts @@ -0,0 +1,3 @@ +export * from './parseBoolean.pipe'; +export * from './parseNumber.pipe'; +export * from './parseDate.pipe'; diff --git a/lib/pipes/parseBoolean.pipe.spec.ts b/lib/pipes/parsers/parseBoolean.pipe.spec.ts similarity index 81% rename from lib/pipes/parseBoolean.pipe.spec.ts rename to lib/pipes/parsers/parseBoolean.pipe.spec.ts index 98789f71..af083091 100644 --- a/lib/pipes/parseBoolean.pipe.spec.ts +++ b/lib/pipes/parsers/parseBoolean.pipe.spec.ts @@ -9,6 +9,9 @@ describe('ParseBooleanPipe', () => { it('Should throw required error the given value is empty', () => expect(() => ParseBooleanPipe({ nullable: false })('')).toThrow()); + it('Should pass without a value when nullable', () => + expect(ParseBooleanPipe({ nullable: true })(undefined)).toStrictEqual(undefined)); + it('Should throw when the given string is not a boolean string', () => expect(() => ParseBooleanPipe()('test')).toThrow()); }); diff --git a/lib/pipes/parseBoolean.pipe.ts b/lib/pipes/parsers/parseBoolean.pipe.ts similarity index 66% rename from lib/pipes/parseBoolean.pipe.ts rename to lib/pipes/parsers/parseBoolean.pipe.ts index 9c82fb80..630d97a1 100644 --- a/lib/pipes/parseBoolean.pipe.ts +++ b/lib/pipes/parsers/parseBoolean.pipe.ts @@ -1,11 +1,16 @@ -import { BadRequestException } from '../exceptions'; -import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe'; -import { validatePipeOptions } from './validatePipeOptions'; +import { BadRequestException } from '../../exceptions'; +import type { ParameterPipe, PipeOptions, PipeMetadata } from '../ParameterPipe'; +import { validateNullable } from '../validateNullable'; +import { validatePipeOptions } from '../validatePipeOptions'; export function ParseBooleanPipe(options?: PipeOptions): ParameterPipe { return (value: any, metadata?: PipeMetadata) => { validatePipeOptions(value, metadata?.name, options); + if (validateNullable(value, options?.nullable)) { + return undefined; + } + if (value === true || value === 'true') { return true; } diff --git a/lib/pipes/parseDate.pipe.spec.ts b/lib/pipes/parsers/parseDate.pipe.spec.ts similarity index 90% rename from lib/pipes/parseDate.pipe.spec.ts rename to lib/pipes/parsers/parseDate.pipe.spec.ts index 35f0b389..63db722d 100644 --- a/lib/pipes/parseDate.pipe.spec.ts +++ b/lib/pipes/parsers/parseDate.pipe.spec.ts @@ -33,4 +33,7 @@ describe('ParseDatePipe', () => { it('Should throw when the given value is `null`.', () => { expect(() => ParseDatePipe()(null)).toThrow(); }); + + it('Should pass without a value when nullable', () => + expect(ParseDatePipe({ nullable: true })(undefined)).toStrictEqual(undefined)); }); diff --git a/lib/pipes/parseDate.pipe.ts b/lib/pipes/parsers/parseDate.pipe.ts similarity index 87% rename from lib/pipes/parseDate.pipe.ts rename to lib/pipes/parsers/parseDate.pipe.ts index 24954418..043551cc 100644 --- a/lib/pipes/parseDate.pipe.ts +++ b/lib/pipes/parsers/parseDate.pipe.ts @@ -1,6 +1,7 @@ -import { BadRequestException } from '../exceptions'; -import type { PipeMetadata, PipeOptions } from './ParameterPipe'; -import { validatePipeOptions } from './validatePipeOptions'; +import { BadRequestException } from '../../exceptions'; +import type { PipeMetadata, PipeOptions } from '../ParameterPipe'; +import { validateNullable } from '../validateNullable'; +import { validatePipeOptions } from '../validatePipeOptions'; // The following variables and functions are taken from the validator.js (https://github.com/validatorjs/validator.js/blob/master/src/lib/isISO8601.js) @@ -43,6 +44,10 @@ export function ParseDatePipe(options?: PipeOptions) { return (value: any, metadata?: PipeMetadata) => { validatePipeOptions(value, metadata?.name, options); + if (validateNullable(value, options?.nullable)) { + return undefined; + } + if (value && !isISO8601(value, { strict: true })) { throw new BadRequestException( `Validation failed${metadata?.name ? ` for ${metadata.name}` : ''} (date string is expected)` diff --git a/lib/pipes/parseNumber.pipe.spec.ts b/lib/pipes/parsers/parseNumber.pipe.spec.ts similarity index 80% rename from lib/pipes/parseNumber.pipe.spec.ts rename to lib/pipes/parsers/parseNumber.pipe.spec.ts index 1e97ada0..076216d7 100644 --- a/lib/pipes/parseNumber.pipe.spec.ts +++ b/lib/pipes/parsers/parseNumber.pipe.spec.ts @@ -9,6 +9,9 @@ describe('ParseNumberPipe', () => { it('Should throw required error the given value is empty', () => expect(() => ParseNumberPipe({ nullable: false })('')).toThrow()); + it('Should pass without a value when nullable', () => + expect(ParseNumberPipe({ nullable: true })(undefined)).toStrictEqual(undefined)); + it('Should throw when the given string is not a numeric string', () => expect(() => ParseNumberPipe()('test')).toThrow()); }); diff --git a/lib/pipes/parseNumber.pipe.ts b/lib/pipes/parsers/parseNumber.pipe.ts similarity index 67% rename from lib/pipes/parseNumber.pipe.ts rename to lib/pipes/parsers/parseNumber.pipe.ts index 9bcc0167..dcfc713b 100644 --- a/lib/pipes/parseNumber.pipe.ts +++ b/lib/pipes/parsers/parseNumber.pipe.ts @@ -1,11 +1,16 @@ -import { BadRequestException } from '../exceptions'; -import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe'; -import { validatePipeOptions } from './validatePipeOptions'; +import { BadRequestException } from '../../exceptions'; +import type { ParameterPipe, PipeOptions, PipeMetadata } from '../ParameterPipe'; +import { validateNullable } from '../validateNullable'; +import { validatePipeOptions } from '../validatePipeOptions'; export function ParseNumberPipe(options?: PipeOptions): ParameterPipe { return (value: any, metadata?: PipeMetadata) => { validatePipeOptions(value, metadata?.name, options); + if (validateNullable(value, options?.nullable)) { + return undefined; + } + const isNumeric = ['string', 'number'].includes(typeof value) && !isNaN(parseFloat(value)) && isFinite(value); if (!isNumeric) { throw new BadRequestException( diff --git a/lib/pipes/validateNullable.ts b/lib/pipes/validateNullable.ts new file mode 100644 index 00000000..3c82696b --- /dev/null +++ b/lib/pipes/validateNullable.ts @@ -0,0 +1,3 @@ +export function validateNullable(value: any, nullable?: boolean): boolean { + return !!nullable && value == null; +} diff --git a/lib/pipes/validators/index.ts b/lib/pipes/validators/index.ts new file mode 100644 index 00000000..59e7f67f --- /dev/null +++ b/lib/pipes/validators/index.ts @@ -0,0 +1,2 @@ +export * from './validation.pipe'; +export * from './validateEnum.pipe'; diff --git a/lib/pipes/validateEnum.pipe.spec.ts b/lib/pipes/validators/validateEnum.pipe.spec.ts similarity index 100% rename from lib/pipes/validateEnum.pipe.spec.ts rename to lib/pipes/validators/validateEnum.pipe.spec.ts diff --git a/lib/pipes/validateEnum.pipe.ts b/lib/pipes/validators/validateEnum.pipe.ts similarity index 85% rename from lib/pipes/validateEnum.pipe.ts rename to lib/pipes/validators/validateEnum.pipe.ts index aa4f6ed5..12b1b3ce 100644 --- a/lib/pipes/validateEnum.pipe.ts +++ b/lib/pipes/validators/validateEnum.pipe.ts @@ -1,6 +1,6 @@ -import { BadRequestException } from '../exceptions'; -import type { ParameterPipe, PipeOptions, PipeMetadata } from './ParameterPipe'; -import { validatePipeOptions } from './validatePipeOptions'; +import { BadRequestException } from '../../exceptions'; +import type { ParameterPipe, PipeOptions, PipeMetadata } from '../ParameterPipe'; +import { validatePipeOptions } from '../validatePipeOptions'; interface ValidateEnumPipeOptions> extends PipeOptions { type: T; diff --git a/lib/pipes/validators/validation.pipe.spec.ts b/lib/pipes/validators/validation.pipe.spec.ts new file mode 100644 index 00000000..ef80b556 --- /dev/null +++ b/lib/pipes/validators/validation.pipe.spec.ts @@ -0,0 +1,9 @@ +import { ValidationPipe } from './validation.pipe'; + +describe('ValidationPipe', () => { + it('Should return the value as is when there is no meta type defined.', () => + expect(ValidationPipe()({ firstName: 'Uncle', lastName: 'Bob' })).toMatchObject({ + firstName: 'Uncle', + lastName: 'Bob' + })); +}); diff --git a/lib/pipes/validation.pipe.ts b/lib/pipes/validators/validation.pipe.ts similarity index 78% rename from lib/pipes/validation.pipe.ts rename to lib/pipes/validators/validation.pipe.ts index e4b00a00..36b0cbf9 100644 --- a/lib/pipes/validation.pipe.ts +++ b/lib/pipes/validators/validation.pipe.ts @@ -1,7 +1,7 @@ import type { ClassTransformOptions } from 'class-transformer'; import type { ValidatorOptions } from 'class-validator'; -import { validateObject } from '../internals/classValidator'; -import type { ParameterPipe, PipeMetadata } from './ParameterPipe'; +import { validateObject } from '../../internals/classValidator'; +import type { ParameterPipe, PipeMetadata } from '../ParameterPipe'; export interface ValidationPipeOptions extends ValidatorOptions { transformOptions?: ClassTransformOptions; diff --git a/test/e2e-stream.test.ts b/test/e2e-stream.test.ts new file mode 100644 index 00000000..5a023de4 --- /dev/null +++ b/test/e2e-stream.test.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import fs from 'fs'; +import express from 'express'; +import request from 'supertest'; +import { createHandler, Get } from '../lib'; + +class TestHandler { + @Get() + public getStream() { + fs.writeFileSync('./test-stream.txt', 'hello stream!'); + return fs.createReadStream('./test-stream.txt'); + } +} + +describe('E2E', () => { + let server: express.Express; + beforeAll(() => { + server = express(); + server.use(express.json()); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + server.all('/', createHandler(TestHandler)); + }); + + afterAll(() => fs.unlinkSync('./test-stream.txt')); + + it('Should return file contents from Stream object.', () => + request(server) + .get('/') + .expect(200) + .then(res => + expect(res).toMatchObject({ + header: { + 'transfer-encoding': 'chunked' + }, + text: 'hello stream!' + }) + )); +}); diff --git a/lib/e2e.test.ts b/test/e2e.test.ts similarity index 69% rename from lib/e2e.test.ts rename to test/e2e.test.ts index 332f20ec..fd75e28d 100644 --- a/lib/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,20 +1,43 @@ import 'reflect-metadata'; -import { IsBoolean, IsDate, IsEnum, IsInt, IsNotEmpty, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsDate, IsEnum, IsInt, IsNotEmpty, IsOptional, ValidateNested } from 'class-validator'; import express from 'express'; import type { NextApiRequest, NextApiResponse } from 'next'; import request from 'supertest'; -import { createHandler } from './createHandler'; -import { Body, Delete, Get, Header, HttpCode, Post, Put, Query, Req, Res, Response, SetHeader } from './decorators'; -import { ValidationPipe } from './pipes'; -import { ParseBooleanPipe } from './pipes/parseBoolean.pipe'; -import { ParseDatePipe } from './pipes/parseDate.pipe'; -import { ParseNumberPipe } from './pipes/parseNumber.pipe'; +import { + createHandler, + Body, + Delete, + Get, + Header, + HttpCode, + Post, + Put, + Query, + Req, + Res, + Response, + SetHeader, + ValidationPipe, + ParseBooleanPipe, + ParseDatePipe, + ParseNumberPipe, + NotFoundException +} from '../lib'; enum CreateSource { ONLINE = 'online', OFFLINE = 'offline' } +class Address { + @IsNotEmpty() + public city!: string; + + @IsNotEmpty() + public country!: string; +} + class CreateDto { @IsNotEmpty() public firstName!: string; @@ -38,6 +61,17 @@ class CreateDto { @IsEnum(CreateSource) @IsOptional() public source?: CreateSource; + + @Type(() => Address) + @ValidateNested() + @IsOptional() + public addresses?: Address[]; +} + +class QueryDto { + @IsOptional() + @IsEnum(CreateSource) + public source?: CreateSource; } @SetHeader('X-Api', 'true') @@ -53,6 +87,10 @@ class TestHandler { @Query('redirect', ParseBooleanPipe) redirect: boolean, @Query('startAt', ParseDatePipe) startAt: Date ) { + if (id !== 'my-id') { + throw new NotFoundException('Invalid ID'); + } + return { contentType, id, @@ -64,11 +102,15 @@ class TestHandler { }; } - @HttpCode(201) @Post() + @HttpCode(201) @SetHeader('X-Method', 'create') - public create(@Header('Content-Type') contentType: string, @Body(ValidationPipe) body: CreateDto) { - return { contentType, receivedBody: body, test: this.testField, instanceOf: body instanceof CreateDto }; + public create( + @Query(ValidationPipe) query: QueryDto, + @Header('Content-Type') contentType: string, + @Body(ValidationPipe) body: CreateDto + ) { + return { ...query, contentType, receivedBody: body, test: this.testField, instanceOf: body instanceof CreateDto }; } @Put() @@ -102,7 +144,7 @@ describe('E2E', () => { server.all('/', createHandler(TestHandler)); }); - it('read', () => + it('Should successfully `GET` the request with a 200 status code.', () => request(server) .get('/?id=my-id&step=1&redirect=true&startAt=2021-01-01T22:00:00') .set('Content-Type', 'application/json') @@ -124,7 +166,20 @@ describe('E2E', () => { }) )); - it('read without "step"', () => + it('Should throw a 404 error when an invalid ID is given.', () => + request(server) + .get('/?id=invalid-id&step=1&redirect=true&startAt=2021-01-01T22:00:00') + .set('Content-Type', 'application/json') + .expect(404) + .then(res => + expect(res).toMatchObject({ + body: { + message: 'Invalid ID' + } + }) + )); + + it('Should return a 400 error when a required parameter is missing.', () => request(server) .get('/?id=my-id&redirect=true') .set('Content-Type', 'application/json') @@ -137,9 +192,9 @@ describe('E2E', () => { }) )); - it('create', () => + it('Should successfully `POST` the request with a 201 status code.', () => request(server) - .post('/') + .post('/?source=online') .send({ firstName: 'Ada', lastName: 'Lovelace', @@ -155,6 +210,7 @@ describe('E2E', () => { 'x-method': 'create' }, body: { + source: CreateSource.ONLINE, contentType: 'application/json', test: 'test', instanceOf: true, @@ -167,7 +223,27 @@ describe('E2E', () => { }) )); - it('Returns error for create', () => + it('Should return a 400 error when "addresses[0].country" is not set.', () => + request(server) + .post('/') + .send({ + firstName: 'Ada', + lastName: 'Lovelace', + dateOfBirth: new Date('1815-12-10'), + birthYear: 1815, + isActive: true, + addresses: [{ city: 'Amsterdam' }] + } as CreateDto) + .expect(400) + .then(res => + expect(res).toMatchObject({ + body: { + errors: expect.arrayContaining([expect.stringContaining('addresses.0.country should not be empty')]) + } + }) + )); + + it('Should return a 400 error when the an invalid enum is given.', () => request(server) .post('/') .send({ @@ -187,7 +263,7 @@ describe('E2E', () => { }) )); - it('update', () => + it('Should successfully `PUT` the request with a 200 status code.', () => request(server) .put('/?id=user-id') .send({ firstName: 'Ada', lastName: 'Lovelace', dateOfBirth: '1815-12-10' }) @@ -211,7 +287,7 @@ describe('E2E', () => { }) )); - it('delete', () => + it('Should successfully `DELETE` the request with a 200 status code.', () => request(server) .delete('/?id=user-id') .send({ firstName: 'Ada', lastName: 'Lovelace', dateOfBirth: '1815-12-10' }) @@ -235,7 +311,7 @@ describe('E2E', () => { }) )); - it('should throw express style 404 for an undefined http verb', () => + it('Should return a express style 404 for an undefined HTTP verb.', () => request(server) .patch('/') .set('Content-Type', 'application/json') diff --git a/tsconfig.json b/tsconfig.json index 72ed4526..851fb5d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "moduleResolution": "node" }, "exclude": ["node_modules"], - "include": ["lib/**/*.ts"] -} \ No newline at end of file + "include": ["lib/**/*.ts", "test/**/*.ts"] +}