Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v1.3.0 #36

Merged
merged 42 commits into from
Mar 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
dac6000
fix(exceptions): corrected NotFoundException name prop
Mar 5, 2021
0bd73b2
refactor(exceptions): added default error messages
Mar 5, 2021
4d081cd
feat(exceptions): added more common http exceptions
Mar 5, 2021
377d55b
test(exceptions): added HttpException spec
Mar 5, 2021
676796d
test(exceptions): added tests for common errors + updated test structure
Mar 5, 2021
4665c52
docs(expections): added new build-in exceptions to the readme
Mar 5, 2021
6a7ff64
docs(pipes): added enum and date pipes in the readme
Mar 5, 2021
6978dcd
feat(exceptions): added UnprocessableEntityException
Mar 5, 2021
491fc29
refactor(test): moved test folder outside lib
Mar 5, 2021
681cf2e
docs: simplified basic example in readme
Mar 5, 2021
f5a6ce6
test(e2e): added exception test in e2e tests
Mar 5, 2021
248e638
chore(exceptions): reflect web standards in default messages
Mar 5, 2021
97f3ed0
test(e2e): updated test description
Mar 5, 2021
5f9dcb7
test: coverage report path
ggurkal Mar 5, 2021
c0f399b
docs(readme): updated default error messages
Mar 5, 2021
e0fdfc3
Merge branch 'refactor/exceptions' of github.com:storyofams/next-api-…
Mar 5, 2021
0ca6370
Merge pull request #28 from storyofams/refactor/exceptions
ggurkal Mar 5, 2021
a7b4f3b
refactor: bundled unit tests with related files
Mar 5, 2021
7284b80
docs(readme): updated decorators and pipes in readme
Mar 8, 2021
e4bed0f
chore(test): normalized test descriptions
Mar 8, 2021
a0a7d52
chore: remove redundant parts
ggurkal Mar 8, 2021
e1af0d3
test: internals
ggurkal Mar 8, 2021
6bf7a64
test: validation pipe
ggurkal Mar 8, 2021
e808be7
test(e2e): nested validation & query with pipe
ggurkal Mar 8, 2021
d1dfeec
test(e2e): return stream
ggurkal Mar 8, 2021
e8c5903
Merge pull request #31 from storyofams/test/more-coverage
leeuwis Mar 9, 2021
ce5622c
refactor: general folder structure
Mar 9, 2021
bf27cac
docs(readme): added footnote to Res decorator
Mar 9, 2021
81eb8b1
Merge pull request #29 from storyofams/refactor/test-structure
ggurkal Mar 9, 2021
4487753
docs(readme): added motivation section
Mar 9, 2021
4276a67
docs(readme): updated babel configuration instructions
Mar 9, 2021
d809672
docs(readme): clarified Res decorator behaviour
Mar 9, 2021
9fa8824
docs(readme): changed json code blocks to json5
leeuwis Mar 9, 2021
38331ef
docs(readme): added @babel/core to instructions
leeuwis Mar 10, 2021
32bcff1
Merge pull request #32 from storyofams/docs/motivation
ggurkal Mar 10, 2021
5268ad1
refactor(pipes): allow pipe fn to return undefined for nullable
ggurkal Mar 10, 2021
4c5acd1
chore(pipes): add nullable validation fn
ggurkal Mar 10, 2021
1e6e0cc
refactor(parsers): nullable check
ggurkal Mar 10, 2021
61270a5
test(parsers): add nullable tests
ggurkal Mar 10, 2021
16d25a2
Merge pull request #33 from storyofams/fix/nullable-check
leeuwis Mar 13, 2021
a822ebc
Merge branch 'master' into beta
ggurkal Mar 13, 2021
2b9d833
Merge branch 'master' into beta
ggurkal Mar 13, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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