Skip to content
This repository was archived by the owner on Apr 9, 2024. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
module.exports = {
extends: ['skuba'],
rules: {
'@typescript-eslint/require-await': 'off',
},
};
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# @seek/slowify
# 🦥 slowify

Fastify plugins for s̶l̶o̶w̶i̶n̶g̶ ̶d̶o̶w̶n̶ ̶y̶o̶u̶r̶ ̶s̶e̶r̶v̶e̶r̶ SEEK-standard tracing, logging and metrics

[![Powered by skuba](https://img.shields.io/badge/🤿%20skuba-powered-009DC4)](https://github.com/seek-oss/skuba)

Expand Down
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,20 @@
"path": "cz-conventional-changelog"
}
},
"dependencies": {},
"dependencies": {
"@fastify/accepts": "^4.2.0",
"fastify-plugin": "^4.5.0"
},
"devDependencies": {
"@types/node": "^16.18.3",
"@types/supertest": "2.0.12",
"commitizen": "^4.2.4",
"skuba": "5.1.1"
"fastify": "4.17.0",
"skuba": "6.2.0",
"supertest": "6.3.3"
},
"peerDependencies": {
"fastify": "4"
},
"skuba": {
"entryPoint": "src/index.ts",
Expand Down
57 changes: 57 additions & 0 deletions src/errorPlugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 🦥 Error Plugin 🦥

## Introduction

Catches errors thrown from downstream, as specified here:

<https://www.fastify.io/docs/latest/Reference/Errors/#catching-uncaught-errors-in-fastify>

## Usage

```typescript
import { ErrorPlugin } from '@seek/slowify';
import { fastify } from 'fastify';
import { logger } from 'src/framework/logging';

export const createApp = async () => {
const server = fastify();
await server.register(ErrorPlugin.plugin);

await server.ready();
return server;
};
```

## JsonResponse

`JsonResponse` is a custom error type used to support JSON error response bodies. The default error handler in Fastify only allows for customisation of the `message` field. This plugin allows for additional fields to be provided to the caller. Its constructor takes a `statusCode` number, `message` string and a `body` value. If the request accepts JSON then the error response will include the JSON encoded body.

```typescript
import { ErrorPlugin } from '@seek/slowify';

fastify.get('/', async (req, reply) => {
throw new ErrorPlugin.JsonResponse(400, 'Bad input', {
message: 'Bad input',
invalidFields: { '/path/to/field': 'Value out of range' },
});
});
```

The caller will be shown the following 400 response

```json
{
"message": "Bad Input",
"invalidFields": { "/path/to/field": "Value out of range" }
}
```

You can also bring your own child Error class by exposing an isJsonResponse property set to true.

## Handling Unknown Errors

The default [Fastify error handler](https://www.fastify.io/docs/latest/Reference/Reply/#errors) will return a 500 error with the `message` field on any error thrown, to the caller. This may unintentionally reveal too much internal information to the caller.

This plugin will instead return a generic 500 error response to the caller instead for any error which does not contain a `statusCode` or `status` field. The logger provided to the error plugin is then called with the following call: `logger.error({ err }, 'unknown error')`.

This error plugin stores the error thrown in the Fastify request object under the exported const symbol `ERROR_STATE_KEY` if you wish to access it yourself.
92 changes: 92 additions & 0 deletions src/errorPlugin/errorPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import fastify, { type FastifyBaseLogger } from 'fastify';

import {
agentFromApp,
agentFromPlugins,
mockRouteHandler,
router,
} from '../testing/server';

import { JsonResponse, plugin } from './errorPlugin';

describe('errorPlugin', () => {
const errorPlugin = plugin;

afterEach(() => {
jest.resetAllMocks();
});

it('exposes a thrown 4xx `JsonResponse` as JSON', async () => {
mockRouteHandler.mockImplementation(async () => {
throw new JsonResponse(400, 'bad', { message: 'bad' });
});

const agent = await agentFromPlugins(errorPlugin);
await agent.get('/').expect(400, { message: 'bad' });
});

it('exposes an error like object with a statusCode', async () => {
mockRouteHandler.mockImplementation(async () =>
Promise.reject({ statusCode: 400, message: 'bad' }),
);

const agent = await agentFromPlugins(errorPlugin);
await agent.get('/').expect(400, 'bad');
});

it('redacts a thrown 5xx error', async () => {
mockRouteHandler.mockImplementation(async () => {
throw new JsonResponse(502, 'bad', { message: 'bad' });
});

const agent = await agentFromPlugins(errorPlugin);
await agent.get('/').expect(502, '');
});

it('returns a JSON payload when by default', async () => {
mockRouteHandler.mockImplementation(async () => {
throw new JsonResponse(400, 'bad', { message: 'bad' });
});

const agent = await agentFromPlugins(errorPlugin);
await agent.get('/').expect(400, { message: 'bad' });
});

it('returns a plain text payload when accept is text/plain', async () => {
mockRouteHandler.mockImplementation(async () => {
throw new JsonResponse(400, 'bad', { message: 'bad' });
});

const agent = await agentFromPlugins(errorPlugin);
await agent.get('/').set('accept', 'plain/text').expect(400, 'bad');
});

it('redacts a non http error and logs the unknown error', async () => {
const unknownError = new Error('bad');
mockRouteHandler.mockImplementation(async () => {
throw unknownError;
});
const mockLogger = {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
fatal: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
child: jest.fn(() => mockLogger),
} as Partial<FastifyBaseLogger> as FastifyBaseLogger;

const app = fastify({
logger: mockLogger,
});
await app.register(errorPlugin);
await app.register(router);
await app.ready();

await agentFromApp(app).get('/').expect(500, '');
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: unknownError },
'unknown error',
);
});
});
78 changes: 78 additions & 0 deletions src/errorPlugin/errorPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fastifyAccepts } from '@fastify/accepts';
import type { FastifyPluginAsync } from 'fastify';
import { fastifyPlugin } from 'fastify-plugin';

declare module 'fastify' {
interface FastifyRequest {
[ERROR_STATE_KEY]?: unknown;
}
}

export const ERROR_STATE_KEY = Symbol('seek-slowify-error');

const isObject = (value: unknown): value is Record<PropertyKey, unknown> =>
typeof value === 'object' && value !== null;

/**
* Custom error type supporting JSON response bodies
*
* The `handle` middleware will return either `message` or `body` depending on
* the request's `Accept` header.
*/
export class JsonResponse extends Error {
/**
* The property used by `handle` to infer that this error contains a body that
* can be exposed in the HTTP response.
*/
public isJsonResponse = true as const;

/**
* Creates a new `JsonResponse`
*
* @param statusCode - The status code to show in the response
*
* @param message - Plain text message used for requests preferring
* `text/plain`. This is also used as the `Error` superclass
* message.
*
* @param body - JavaScript value used for requests accepting
* `application/json`. This is encoded as JSON in the response.
*/
constructor(
public statusCode: number,
public message: string,
public body?: Record<string, unknown>,
) {
super(message);
}
}

export const plugin: FastifyPluginAsync = fastifyPlugin(
async (fastify, _opts) => {
await fastify.register(fastifyAccepts);
fastify.setErrorHandler((err: unknown, req, reply) => {
req[ERROR_STATE_KEY] = err;

if (
!isObject(err) ||
!(typeof err.statusCode === 'number' || typeof err.status === 'number')
) {
fastify.log.error({ err }, 'unknown error');
return reply.code(500).send('');
}

const statusCode = (err.statusCode ?? err.status) as number;

const expose = statusCode < 500;
if (
expose &&
err.isJsonResponse === true &&
req.accepts().type(['json'])
) {
return reply.code(statusCode).send(err.body);
}

return reply.code(statusCode).send((expose && err.message) || '');
});
},
);
5 changes: 0 additions & 5 deletions src/index.test.ts

This file was deleted.

8 changes: 1 addition & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1 @@
/**
* Writes the module name to stdout.
* Thrilling stuff.
*/
export const log = () =>
/* eslint-disable-next-line no-console */
console.log('@seek/slowify');
export * as ErrorPlugin from './errorPlugin/errorPlugin';
46 changes: 46 additions & 0 deletions src/testing/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
type FastifyInstance,
type FastifyPluginAsync,
type FastifyPluginCallback,
type RouteHandler,
fastify,
} from 'fastify';
import request from 'supertest';

/**
* Create a new SuperTest agent from a Fastify application.
*/
export const agentFromApp = (app: FastifyInstance) => request.agent(app.server);

/**
* Default route handler which mmocks the root get(`/`) handler.
*/
export const mockRouteHandler = jest.fn<
ReturnType<RouteHandler>,
Parameters<RouteHandler>
>();

export const router: FastifyPluginAsync = async (app, _opts) => {
app.get('/', mockRouteHandler);
};

const createApp = async (
...plugins: (FastifyPluginAsync | FastifyPluginCallback)[]
) => {
const app = fastify();
await Promise.all(plugins.map((plugin) => app.register(plugin)));
await app.register(router);
await app.ready();
return app;
};

/**
* Create a new SuperTest agent from a set of Fastify plugins.
*/
export const agentFromPlugins = async (
...plugins: (FastifyPluginAsync | FastifyPluginCallback)[]
) => {
const app = await createApp(...plugins);

return agentFromApp(app);
};
Loading