Skip to content

Commit

Permalink
Merge pull request #147 from storyofams/feat/catch-decorator
Browse files Browse the repository at this point in the history
[feat] catch decorator
  • Loading branch information
ggurkal authored Jun 14, 2021
2 parents 80dfc5e + 55abf61 commit c2c9b6b
Show file tree
Hide file tree
Showing 9 changed files with 264 additions and 14 deletions.
31 changes: 31 additions & 0 deletions lib/decorators/catch.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ClassConstructor } from 'class-transformer';
import type { NextApiRequest, NextApiResponse } from 'next';

export const CATCH_TOKEN = Symbol('ams:next:catch');

type ExceptionHandlerFunction<T> = (error: T, req: NextApiRequest, res: NextApiResponse) => void | Promise<void>;

export interface ExceptionHandler<T> {
handler: ExceptionHandlerFunction<T>;
exceptionType?: ClassConstructor<T>;
}

export function Catch<T>(
fn: ExceptionHandler<T>['handler'],
type?: ExceptionHandler<T>['exceptionType']
): ClassDecorator & MethodDecorator {
return function (target: Function | object, propertyKey?: string | symbol) {
const handlers: ExceptionHandler<T>[] =
(propertyKey
? Reflect.getMetadata(CATCH_TOKEN, target.constructor, propertyKey)
: Reflect.getMetadata(CATCH_TOKEN, target)) ?? [];

handlers.unshift({ handler: fn, exceptionType: type });

if (propertyKey) {
Reflect.defineMetadata(CATCH_TOKEN, handlers, target.constructor, propertyKey);
} else {
Reflect.defineMetadata(CATCH_TOKEN, handlers, target);
}
};
}
2 changes: 1 addition & 1 deletion lib/decorators/httpMethod.decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function applyHttpMethod(verb: HttpVerb, path: string) {

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

applyHandler(target, propertyKey, descriptor);
return applyHandler(target, propertyKey, descriptor);
};
}

Expand Down
1 change: 1 addition & 0 deletions lib/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './parameter.decorators';
export * from './setHeader.decorator';
export * from './download.decorator';
export * from './middleware.decorators';
export * from './catch.decorator';
3 changes: 2 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export {
createMiddlewareDecorator,
UploadedFile,
UploadedFiles,
createParamDecorator
createParamDecorator,
Catch
} from './decorators';
export type { Middleware, NextFunction, NextMiddleware } from './decorators';
export * from './exceptions';
Expand Down
46 changes: 46 additions & 0 deletions lib/internals/exceptionHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { CATCH_TOKEN, ExceptionHandler } from '../decorators';
import { HttpException } from '../exceptions';

function getExceptionHandlers(target: Function | object, propertyKey: string | symbol): ExceptionHandler<any>[] {
const definedExceptionHandler =
Reflect.getMetadata(CATCH_TOKEN, target.constructor, propertyKey) ??
Reflect.getMetadata(CATCH_TOKEN, target.constructor);

return definedExceptionHandler;
}

function defaultExceptionHandler(exception: unknown, res: NextApiResponse) {
const statusCode = exception instanceof HttpException ? exception.statusCode : 500;
const message = exception instanceof HttpException ? exception.message : 'An unknown error occurred.';
const errors = exception instanceof HttpException && exception.errors?.length ? exception.errors : [message];

res.status(statusCode).json({
statusCode,
message,
errors,
stack: exception instanceof Error && process.env.NODE_ENV === 'development' ? exception.stack : undefined
});
}

export async function handleException(
target: Function | object,
propertyKey: string | symbol,
exception: unknown,
req: NextApiRequest,
res: NextApiResponse
): Promise<void> {
const exceptionHandlers: ExceptionHandler<any>[] | undefined = getExceptionHandlers(target, propertyKey);

if (exceptionHandlers) {
for (const exceptionHandler of exceptionHandlers) {
if (exceptionHandler.exceptionType && exception instanceof exceptionHandler.exceptionType) {
return exceptionHandler.handler.call(null, exception, req, res);
} else if (!exceptionHandler.exceptionType) {
return exceptionHandler.handler.call(null, exception, req, res);
}
}
}

return defaultExceptionHandler(exception, res);
}
18 changes: 6 additions & 12 deletions lib/internals/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
NextMiddleware,
PARAMETER_TOKEN
} from '../decorators';
import { HttpException } from '../exceptions';
import { handleException } from './exceptionHandler';
import { getParameterValue } from './getParameterValue';
import { handleMulterError } from './multerError.util';

Expand Down Expand Up @@ -145,8 +145,9 @@ export function applyHandler(
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<any>
): void {
): TypedPropertyDescriptor<any> {
const originalHandler = descriptor.value;

descriptor.value = async function (req: NextApiRequest, res: NextApiResponse) {
const classMiddlewares: Middleware[] | undefined = Reflect.getMetadata(MIDDLEWARE_TOKEN, target.constructor);
const methodMiddlewares: Middleware[] | undefined = Reflect.getMetadata(
Expand All @@ -159,16 +160,9 @@ export function applyHandler(
await runMiddlewares.call(this, [...(classMiddlewares ?? []), ...(methodMiddlewares ?? [])], req, res);
await runMainLayer.call(this, target, propertyKey, originalHandler, req, res);
} catch (err) {
const statusCode = err instanceof HttpException ? err.statusCode : 500;
const message = err instanceof HttpException ? err.message : 'An unknown error occurred.';
const errors = err instanceof HttpException && err.errors?.length ? err.errors : [message];

res.status(statusCode).json({
statusCode,
message,
errors,
stack: 'stack' in err && process.env.NODE_ENV === 'development' ? err.stack : undefined
});
await handleException(target, propertyKey, err, req, res);
}
};

return descriptor;
}
121 changes: 121 additions & 0 deletions test/e2e-catch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { NextApiRequest, NextApiResponse } from 'next';
import 'reflect-metadata';
import request from 'supertest';
import {
createHandler,
Get,
UnauthorizedException,
NotFoundException,
Query,
UseMiddleware,
ParseNumberPipe,
BadRequestException,
Catch,
NextFunction
} from '../lib';
import { setupServer } from './setupServer';

const ARTICLES = [
{ id: 1, title: 'Hello world example' },
{ id: 2, title: 'Handling errors' },
{ id: 3, title: 'Validation' }
];

function unauthorizedExceptionHandler(error: UnauthorizedException, _req: NextApiRequest, res: NextApiResponse): void {
res.status(200).json({ error: true, errorMessage: error.message });
}

async function notFoundExceptionHandler(
exception: NotFoundException,
_req: NextApiRequest,
res: NextApiResponse
): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
res.status(200).json({ notFound: true, message: exception.message });
}

function generalExceptionHandler(error: Error, _req: NextApiRequest, res: NextApiResponse): void {
res.status(500).json({ error: true, name: error.name, msg: error.message });
}

function exceptionHandlerToAvoid(_error: Error, _req: NextApiRequest, res: NextApiResponse): void {
res.status(204).end();
}

@Catch(unauthorizedExceptionHandler, UnauthorizedException)
@Catch(notFoundExceptionHandler, NotFoundException)
class ArticleHandler {
@Get()
@UseMiddleware((req: NextApiRequest, _res: NextApiResponse, next: NextFunction) => {
if (req.query.protected === 'true') {
throw new UnauthorizedException();
}

next();
})
public index(@Query('search') search?: string) {
switch (search) {
case 'forbidden-keyword':
throw new NotFoundException();
case 'another-forbidden-keyword':
throw new BadRequestException();
}

return ARTICLES.filter(({ title }) => (search ? title.includes(search.toLowerCase()) : true));
}

@Get('/details')
@Catch(generalExceptionHandler)
@Catch(exceptionHandlerToAvoid) // `generalExceptionHandler` handles the error, since that's the first one. Therefore, this one gets ignored.
public details(@Query('id', ParseNumberPipe) id: number) {
const article = ARTICLES.find(article => article.id === id);
if (!article) {
throw new Error('Article not found');
}

return article;
}
}

describe('E2E - Catch decorator', () => {
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer(createHandler(ArticleHandler));
});

afterAll(() => {
if ('close' in server && typeof server.close === 'function') {
server.close();
}
});

it('Should return the articles.', () => request(server).get('/api/article').expect(200, ARTICLES));

it('Should handle the error via the "notFoundExceptionHandler".', () =>
request(server).get('/api/article?search=forbidden-keyword').expect(200, { notFound: true, message: 'Not Found' }));

it('Should handle the error via the "unauthorizedExceptionHandler".', () =>
request(server).get('/api/article?protected=true').expect(200, {
error: true,
errorMessage: 'Unauthorized'
}));

it('Should handle the error via the built-in error handler.', () =>
request(server)
.get('/api/article?search=another-forbidden-keyword')
.expect(400, { statusCode: 400, message: 'Bad Request', errors: ['Bad Request'] }));

it('Should return the article.', () => request(server).get('/api/article/details?id=1').expect(200, ARTICLES[0]));

it('Should handle the error via the "generalExceptionHandler".', () =>
request(server).get('/api/article/details?id=99999').expect(500, {
error: true,
name: 'Error',
msg: 'Article not found'
}));

it('Should handle the pipe errors via the "generalExceptionHandler".', () =>
request(server)
.get('/api/article/details')
.expect(500, { error: true, name: 'BadRequestException', msg: 'id is a required parameter.' }));
});
2 changes: 2 additions & 0 deletions website/docs/api/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ slug: /api/decorators
## Class decorators

* `@SetHeader(key: string, value: string)` Sets a header key valur pair for all routes in a handler class.
* `@Catch(handler: (error: unknown, req: NextApiRequest, res: NextApiResponse) => void | Promise<void>, exceptionType?: ClassConstructor)` Creates an exception handler for a handler class.

## Method decorators

* `@SetHeader(key: string, value: string)` Sets a header key value pair for the route that the decorator is applied to.
* `@HttpCode(code: number)` Defines the HTTP response code of the route.
* `@Download()` Marks the method as a download handler for the client, so the returned file can be downloaded by the browser.
* `@Catch(handler: (error: unknown, req: NextApiRequest, res: NextApiResponse) => void | Promise<void>, exceptionType?: ClassConstructor)` Creates an exception handler for a route in a handler class.

### HTTP method decorators

Expand Down
54 changes: 54 additions & 0 deletions website/docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,57 @@ class Events {
}
}
```

### Handling exceptions

Even though we already have a built-in exception handler, you may need more control over the exception handling for specific use cases. For example, add logging or use a different shape for the response object based on some dynamic factors. For that purpose, we provide the `@Catch` decorator.

`@Catch` decorator can either be used for the whole handler (on the class) or can be used for a specific route (on a class method).

Let's create an exception handler for the `ForbiddenException` we created above.

```ts
import { Catch } from '@storyofams/next-api-decorators';

function forbiddenExceptionHandler(
error: ForbiddenException,
req: NextApiRequest,
res: NextApiResponse
) {
Sentry.captureException(err);
res.status(403).end();
}

@Catch(forbiddenExceptionHandler, ForbiddenException)
class Events {
@Get()
public events() {
return 'Our events';
}
}
```

### Catch everything

In case you need the exception handler to catch all errors, you can pass only the handler function to the `@Catch` decorator:

```ts
import { Catch } from '@storyofams/next-api-decorators';

function exceptionHandler(
error: unknown,
req: NextApiRequest,
res: NextApiResponse
) {
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
res.status(200).json({ success: false, error: message });
}

@Catch(exceptionHandler)
class Events {
@Get()
public events() {
return 'Our events';
}
}
```

0 comments on commit c2c9b6b

Please sign in to comment.