Skip to content

Commit 62a24f8

Browse files
authored
Merge pull request #36 from storyofams/beta
Release v1.3.0
2 parents d1f460d + 2b9d833 commit 62a24f8

37 files changed

+408
-99
lines changed

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,3 @@
2424
npm-debug.log*
2525
yarn-debug.log*
2626
yarn-error.log*
27-
28-
# storybook
29-
storybook-static

README.md

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121

2222
---
2323

24-
Collection of decorators to create typed Next.js API routes, with easy request validation and transformation.
24+
This package contains a collection of decorators to create typed Next.js API routes, with easy request validation and transformation.
25+
26+
## Motivation
27+
28+
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.
29+
30+
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.
31+
2532

2633
## Installation
2734

@@ -34,29 +41,35 @@ $ yarn add @storyofams/next-api-decorators
3441
Since decorators are still in proposal state, you need to add the following plugins to your `devDependencies` in order to use them:
3542

3643
```bash
37-
$ yarn add -D babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators babel-plugin-parameter-decorator
44+
$ yarn add -D @babel/core babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators babel-plugin-parameter-decorator
3845
```
3946

40-
Make sure to add the following lines to the `plugins` section in your babel configuration file:
41-
```json
42-
"babel-plugin-transform-typescript-metadata",
43-
["@babel/plugin-proposal-decorators", { "legacy": true }],
44-
"babel-plugin-parameter-decorator",
47+
Make sure to add the following lines to the start of the `plugins` section in your babel configuration file:
48+
```json5
49+
{
50+
"plugins": [
51+
"babel-plugin-transform-typescript-metadata",
52+
["@babel/plugin-proposal-decorators", { "legacy": true }],
53+
"babel-plugin-parameter-decorator",
54+
// ... other plugins
55+
]
56+
}
4557
```
4658

4759
Your `tsconfig.json` needs the following flags:
4860

49-
```json
61+
```json5
5062
"experimentalDecorators": true
5163
```
5264

65+
5366
## Usage
5467

5568
### Basic example
5669

5770
```ts
5871
// pages/api/user.ts
59-
import { createHandler, Get, Post, Query, Body, NotFoundException } from '@storyofams/next-api-decorators';
72+
import { createHandler, Get, Query, NotFoundException } from '@storyofams/next-api-decorators';
6073

6174
class User {
6275
// GET /api/user
@@ -70,12 +83,6 @@ class User {
7083

7184
return user;
7285
}
73-
74-
// POST /api/user
75-
@Post()
76-
public createUser(@Body() body: any) {
77-
return DB.createUser(body);
78-
}
7986
}
8087

8188
export default createHandler(User);
@@ -92,7 +99,8 @@ $ yarn add class-validator class-transformer
9299
Then you can define your DTOs like:
93100

94101
```ts
95-
import { createHandler, Post, Body } from '@storyofams/next-api-decorators';
102+
// pages/api/user.ts
103+
import { createHandler, Post, HttpCode, Body } from '@storyofams/next-api-decorators';
96104
import { IsNotEmpty, IsEmail } from 'class-validator';
97105

98106
class CreateUserDto {
@@ -104,7 +112,9 @@ class CreateUserDto {
104112
}
105113

106114
class User {
115+
// POST /api/user
107116
@Post()
117+
@HttpCode(201)
108118
public createUser(@Body() body: CreateUserDto) {
109119
return User.create(body);
110120
}
@@ -113,6 +123,7 @@ class User {
113123
export default createHandler(User);
114124
```
115125

126+
116127
## Available decorators
117128

118129
### Class decorators
@@ -136,12 +147,13 @@ export default createHandler(User);
136147

137148
| | Description |
138149
| ----------------------- | ------------------------------------------- |
150+
| `@Req()` | Gets the request object. |
151+
| `@Res()`* | Gets the response object. |
139152
| `@Body()` | Gets the request body. |
140153
| `@Query(key: string)` | Gets a query string parameter value by key. |
141154
| `@Header(name: string)` | Gets a header value by name. |
142155

143-
144-
156+
\* 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.
145157

146158
## Built-in pipes
147159

@@ -153,19 +165,24 @@ Pipes are being used to validate and transform incoming values. The pipes can be
153165

154166
⚠️ Beware that they throw when the value is invalid.
155167

156-
| | Description | Remarks |
157-
| ------------------ | ------------------------------------------- | --------------------------------------------- |
158-
| `ParseNumberPipe` | Validates and transforms `Number` strings. | Uses `parseFloat` under the hood |
159-
| `ParseBooleanPipe` | Validates and transforms `Boolean` strings. | Allows `'true'` and `'false'` as valid values |
160-
168+
| | Description | Remarks |
169+
| ------------------ | ------------------------------------------- | -------------------------------------------------- |
170+
| `ParseBooleanPipe` | Validates and transforms `Boolean` strings. | Allows `'true'` and `'false'` as valid values. |
171+
| `ParseDatePipe` | Validates and transforms `Date` strings. | Allows valid `ISO 8601` formatted date strings. |
172+
| `ParseNumberPipe` | Validates and transforms `Number` strings. | Uses `parseFloat` under the hood. |
173+
| `ValidateEnumPipe` | Validates string based on `Enum` values. | Allows strings that are present in the given enum. |
161174

162175
## Exceptions
163176

164-
The following built-in exceptions are provided by this package:
165-
166-
* `NotFoundException`
167-
* `BadRequestException`
177+
The following common exceptions are provided by this package.
168178

179+
| | Status code | Default message |
180+
| ------------------------------ | ----------- | ------------------------- |
181+
| `BadRequestException` | `400` | `'Bad Request'` |
182+
| `UnauthorizedException` | `401` | `'Unauthorized'` |
183+
| `NotFoundException` | `404` | `'Not Found'` |
184+
| `UnprocessableEntityException` | `422` | `'Unprocessable Entity'` |
185+
| `InternalServerErrorException` | `500` | `'Internal Server Error'` |
169186

170187
### Custom exceptions
171188

@@ -175,7 +192,7 @@ Any exception class that extends the base `HttpException` will be handled by the
175192
import { HttpException } from '@storyofams/next-api-decorators';
176193

177194
export class ForbiddenException extends HttpException {
178-
public constructor(message?: string) {
195+
public constructor(message?: string = 'Forbidden') {
179196
super(403, message);
180197
}
181198
}

jest.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"moduleFileExtensions": ["js", "json", "ts"],
3-
"rootDir": "lib",
3+
"rootDir": ".",
44
"testRegex": ".(spec|test).ts$",
55
"transform": {
66
"^.+\\.(t|j)s$": "ts-jest"
77
},
8-
"coverageDirectory": "../coverage",
8+
"coverageDirectory": "coverage",
99
"testEnvironment": "node"
1010
}

lib/decorators/.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"rules": {
33
"@typescript-eslint/ban-types": "off"
44
}
5-
}
5+
}

lib/decorators/httpCode.decorator.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ class Test {
77
public create(): void {}
88
}
99

10-
it('HttpCode decorator should be set.', () =>
10+
it('Should set the HttpCode decorator.', () =>
1111
expect(Reflect.getMetadata(HTTP_CODE_TOKEN, Test, 'create')).toStrictEqual(201));

lib/decorators/httpMethod.decorator.spec.ts renamed to lib/decorators/httpMethod.decorators.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Test {
1919
public delete(): void {}
2020
}
2121

22-
it('HttpMethod decorator should be set.', () => {
22+
it('Should set the HttpMethod decorator.', () => {
2323
const meta = Reflect.getMetadata(HTTP_METHOD_TOKEN, Test);
2424
expect(meta).toBeInstanceOf(Map);
2525
expect(meta).toMatchObject(

lib/decorators/httpMethod.decorators.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function applyHttpMethod(verb: HttpVerb) {
1818

1919
Reflect.defineMetadata(HTTP_METHOD_TOKEN, methods, target.constructor);
2020

21-
Handler(verb)(target, propertyKey, descriptor);
21+
Handler()(target, propertyKey, descriptor);
2222
};
2323
}
2424

lib/decorators/parameter.decorator.spec.ts renamed to lib/decorators/parameter.decorators.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
55
import { Body, PARAMETER_TOKEN, Req, Request, Res, Response, Header, Query } from './parameter.decorators';
66

77
describe('Parameter decorators', () => {
8-
it('Body should be set.', () => {
8+
it('Should set the Body decorator.', () => {
99
class Test {
1010
public index(@Body() body: any) {}
1111
}
@@ -16,7 +16,7 @@ describe('Parameter decorators', () => {
1616
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'body' })]));
1717
});
1818

19-
it('Header should be set.', () => {
19+
it('Should set the Header decorator for the given names.', () => {
2020
class Test {
2121
public index(@Header('Content-Type') contentType: string, @Header('Referer') referer: string): void {}
2222
}
@@ -32,7 +32,7 @@ describe('Parameter decorators', () => {
3232
);
3333
});
3434

35-
it('Query should be set for the whole query string.', () => {
35+
it('Should set the Query decorator for the whole query string.', () => {
3636
class Test {
3737
public index(@Query() query: any) {}
3838
}
@@ -45,7 +45,7 @@ describe('Parameter decorators', () => {
4545
);
4646
});
4747

48-
it('Query parameters should be set.', () => {
48+
it('Should set the Query decorator for the given keys.', () => {
4949
class Test {
5050
public index(
5151
@Query('firstName') firstName: string,
@@ -66,7 +66,7 @@ describe('Parameter decorators', () => {
6666
);
6767
});
6868

69-
it('Req should be set.', () => {
69+
it('Should set the Req decorator.', () => {
7070
class Test {
7171
public index(@Req() req: NextApiRequest) {}
7272
}
@@ -77,7 +77,7 @@ describe('Parameter decorators', () => {
7777
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'request' })]));
7878
});
7979

80-
it('Res should be set.', () => {
80+
it('Should set the Res decorator.', () => {
8181
class Test {
8282
public index(@Res() res: NextApiResponse) {}
8383
}
@@ -88,7 +88,7 @@ describe('Parameter decorators', () => {
8888
expect(meta).toMatchObject(expect.arrayContaining([expect.objectContaining({ index: 0, location: 'response' })]));
8989
});
9090

91-
it('Request and Response should be set.', () => {
91+
it('Should set the Request and Response decoractors (aliases).', () => {
9292
class Test {
9393
public index(@Request() req: NextApiRequest, @Response() res: NextApiResponse) {}
9494
}

lib/decorators/setHeader.decorator.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Test {
88
public index(): void {}
99
}
1010

11-
it('SetHeader should be set.', () => {
11+
it('Should set the SetHeader decorator for the given name.', () => {
1212
const meta = Reflect.getMetadata(HEADER_TOKEN, Test);
1313
const methodMeta = Reflect.getMetadata(HEADER_TOKEN, Test, 'index');
1414

lib/exceptions/BadRequestException.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { HttpException } from './HttpException';
33
export class BadRequestException extends HttpException {
44
public name = 'BadRequestException';
55

6-
public constructor(message?: string, errors?: string[]) {
6+
public constructor(message: string = 'Bad Request', errors?: string[]) {
77
super(400, message, errors);
88
}
99
}

lib/exceptions/HttpException.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
HttpException,
3+
BadRequestException,
4+
InternalServerErrorException,
5+
NotFoundException,
6+
UnauthorizedException,
7+
UnprocessableEntityException
8+
} from '.';
9+
10+
describe('HttpException', () => {
11+
it(`Should use 'HttpException' as name`, () =>
12+
expect(new HttpException(500)).toHaveProperty('name', 'HttpException'));
13+
14+
it('Should set the given number as statusCode', () =>
15+
expect(new HttpException(500)).toHaveProperty('statusCode', 500));
16+
17+
it('Should set the given string as message', () =>
18+
expect(new HttpException(403, 'Forbidden')).toHaveProperty('message', 'Forbidden'));
19+
20+
it('Should set the given array of strings as errors', () =>
21+
expect(
22+
new HttpException(400, 'Bad request', ['First name is required', 'Last name is required'])
23+
).toHaveProperty('errors', ['First name is required', 'Last name is required']));
24+
25+
it('Should set the name, statusCode, message and errors', () =>
26+
expect(new HttpException(400, 'Bad request', ['Invalid email address'])).toMatchObject({
27+
name: 'HttpException',
28+
statusCode: 400,
29+
message: 'Bad request',
30+
errors: ['Invalid email address']
31+
}));
32+
33+
describe('Default errors', () => {
34+
it('Should set the default status codes', () => {
35+
expect(new BadRequestException()).toHaveProperty('statusCode', 400);
36+
expect(new InternalServerErrorException()).toHaveProperty('statusCode', 500);
37+
expect(new NotFoundException()).toHaveProperty('statusCode', 404);
38+
expect(new UnauthorizedException()).toHaveProperty('statusCode', 401);
39+
expect(new UnprocessableEntityException()).toHaveProperty('statusCode', 422);
40+
});
41+
42+
it('Should set the default error messages', () => {
43+
expect(new BadRequestException()).toHaveProperty('message', 'Bad Request');
44+
expect(new InternalServerErrorException()).toHaveProperty('message', 'Internal Server Error');
45+
expect(new NotFoundException()).toHaveProperty('message', 'Not Found');
46+
expect(new UnauthorizedException()).toHaveProperty('message', 'Unauthorized');
47+
expect(new UnprocessableEntityException()).toHaveProperty('message', 'Unprocessable Entity');
48+
});
49+
});
50+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { HttpException } from './HttpException';
2+
3+
export class InternalServerErrorException extends HttpException {
4+
public name = 'InternalServerErrorException';
5+
6+
public constructor(message: string = 'Internal Server Error') {
7+
super(500, message);
8+
}
9+
}

lib/exceptions/NotFoundException.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { HttpException } from './HttpException';
22

33
export class NotFoundException extends HttpException {
4-
public name = 'BadRequestException';
4+
public name = 'NotFoundException';
55

6-
public constructor(message?: string) {
6+
public constructor(message: string = 'Not Found') {
77
super(404, message);
88
}
99
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { HttpException } from './HttpException';
2+
3+
export class UnauthorizedException extends HttpException {
4+
public name = 'UnauthorizedException';
5+
6+
public constructor(message: string = 'Unauthorized') {
7+
super(401, message);
8+
}
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { HttpException } from './HttpException';
2+
3+
export class UnprocessableEntityException extends HttpException {
4+
public name = 'UnprocessableEntityException';
5+
6+
public constructor(message: string = 'Unprocessable Entity', errors?: string[]) {
7+
super(422, message, errors);
8+
}
9+
}

lib/exceptions/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export * from './HttpException';
2-
export * from './NotFoundException';
32
export * from './BadRequestException';
3+
export * from './InternalServerErrorException';
4+
export * from './NotFoundException';
5+
export * from './UnauthorizedException';
6+
export * from './UnprocessableEntityException';

0 commit comments

Comments
 (0)