Skip to content

Commit

Permalink
Merge pull request #36 from storyofams/beta
Browse files Browse the repository at this point in the history
Release v1.3.0
  • Loading branch information
leeuwis authored Mar 13, 2021
2 parents d1f460d + 2b9d833 commit 62a24f8
Show file tree
Hide file tree
Showing 37 changed files with 408 additions and 99 deletions.
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,3 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# storybook
storybook-static
73 changes: 45 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -104,7 +112,9 @@ class CreateUserDto {
}

class User {
// POST /api/user
@Post()
@HttpCode(201)
public createUser(@Body() body: CreateUserDto) {
return User.create(body);
}
Expand All @@ -113,6 +123,7 @@ class User {
export default createHandler(User);
```


## Available decorators

### Class decorators
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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);
}
}
Expand Down
4 changes: 2 additions & 2 deletions jest.config.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion lib/decorators/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"rules": {
"@typescript-eslint/ban-types": "off"
}
}
}
2 changes: 1 addition & 1 deletion lib/decorators/httpCode.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion lib/decorators/httpMethod.decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
}
Expand All @@ -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 {}
}
Expand All @@ -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) {}
}
Expand All @@ -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,
Expand All @@ -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) {}
}
Expand All @@ -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) {}
}
Expand All @@ -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) {}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/decorators/setHeader.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
2 changes: 1 addition & 1 deletion lib/exceptions/BadRequestException.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
50 changes: 50 additions & 0 deletions lib/exceptions/HttpException.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
9 changes: 9 additions & 0 deletions lib/exceptions/InternalServerErrorException.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 2 additions & 2 deletions lib/exceptions/NotFoundException.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
9 changes: 9 additions & 0 deletions lib/exceptions/UnauthorizedException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { HttpException } from './HttpException';

export class UnauthorizedException extends HttpException {
public name = 'UnauthorizedException';

public constructor(message: string = 'Unauthorized') {
super(401, message);
}
}
9 changes: 9 additions & 0 deletions lib/exceptions/UnprocessableEntityException.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 4 additions & 1 deletion lib/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 62a24f8

Please sign in to comment.