I wanted to pause and reflect upon my successes with Fastify while wrapping up a recent project. It goes without saying that every project has its demands that can influence your choices around your stack. In this instance, I needed to rapidly develop and deliver a prototype REST API that met the company’s stringent standards.
Fastify sells itself as being a ‘low-overhead’ web framework. It’s small and extensible but has most of the things you need to get off the ground when writing microservices. There’s a rich library of plugins to choose from for everything else. It is architecturally opinionated, but you’re not strong-armed into using an ORM or MVC. Those choices are still yours to decide upon. If you’re familiar with Express or Hapi, you’ll feel right at home with Fastify.
The most significant requirements were:
- The API should be documented automatically using the OpenAPI Specification.
- The API should provide endpoints for k8s liveness and readiness probes.
- The API should have sufficient tests.
Generating OAS Documentation
As far as I know, Express has no built-in way of describing its routes. I refer to this as ‘route reflection’, but maybe there’s a better term. If I want to generate OAS from my application, I need to be able to get a representation of its structure at the very least.
Fastify has ‘hooks’ that you can use to jump in as different events are fired, and one of those events is the onRoute event which is triggered when a new route is registered. You can effectively build up a list of routes from within a plugin using something like this:
const routes = new Set();
fastify.addHook('onRoute', ({ routePath }) => {
if (routePath && routePath !== '/*') {
routes.add(routePath);
}
});
I could leverage this to cobble together an Open API document on my own, but there’s an excellent plugin that I’ve been using: https://github.com/fastify/fastify-swagger. This plugin not only streamlines document generation but also intelligently incorporates your existing schemas, ensuring a comprehensive and accurate representation of your API.
const app = fastify();
// Register plugin.
app.register(swagger, {
openapi: {
info: {
version: '1.0.0',
title: 'Customers API'
},
tags: [{
name: 'customers',
description: 'Customer APIs'
}]
}
});
// Define routes.
// ...
// For demo only. Use the fastify-swagger-ui plugin instead.
app.get('/docs', {}, async (request, reply) => {
reply.send(app.swagger());
});
Schematics and Types
When you define a route in Fastify, you can pass in a schema option. Your schemas should be in JSON Schema format, but there’s more to this than meets the eye. Wouldn’t it be great if you could derive types from your schematics to use within your route handlers? You can!
My preferred method is to use TypeBox as it’s a nice easy wrapper around JSON Schema. Instead, you use TypeBox’s objects to create a JSON Schema that is all in memory:
import { Static, Type } from "@sinclair/typebox";
export const Customer = Type.Object({
id: Type.String(),
email: Type.String(),
});
export type CustomerType = Static<typeof Customer>;
export const CustomerListResponse = Type.Object({
customers: Type.Array(Customer),
});
I can garnish my handler with a schema and let the type provider take care of type inferences:
import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox';
import { CustomerType, CustomerListResponse } from "./schema";
const router: FastifyPluginAsyncTypebox = async function(fastify) {
fastify.get('/customers', {
schema: {
response: {
200: CustomerListResponse
}
}
}, async (req, reply) => {
const client = await fastify.pg.connect();
try {
const { rows } = await client.query<CustomerType>(
'SELECT id, email FROM customers'
);
return reply.send({
customers: rows
});
} finally {
client.release();
}
});
}
export default router;
Kubernetes Probes
Kubernetes utilises ‘probes’ to assess containers’ health and operational status. These probes inform the Kubelet about two key aspects:
- Readiness. Indicating that the service is ready to accept incoming requests. This is done using a ‘readiness probe’ to ensure the application inside the container has started up correctly and is prepared to handle traffic.
- Liveness: Confirming that the service is functional and running as expected. A ‘liveness probe’ checks whether the container is running and operational. If this probe fails, Kubernetes can restart the container to attempt to restore its functionality.
We’ll introduce two endpoints that the probes can examine. For the liveness probe, we’ll check that the service can return a success code; were it unable to, it would indicate something has jammed the process or that there’s been an unrecoverable error.
app.get('/health/liveness', {
schema: {
tags: ['health']
}
}, async (request, reply) => {
return reply.code(204).send();
}
For the readiness check, it seems fitting to test the database connectivity by running a test query against Postgres. If there are any problems, I issue a ‘service unavailable’ code:
fastify.get('/health/readiness', {
schema: {
tags: ['health']
}
}, async (request, reply) => {
const connection = await fastify.pg.connect();
try {
await connection.query('SELECT 1');
return reply.send({
database: 'up'
});
} catch (err) {
return reply.code(503).send({
database: 'down'
});
} finally {
if (connection) {
await connection.release();
}
}
});
If you’re looking for something pre-made to achieve this, you could look at Terminus which also covers graceful shutdown. I found it easy enough to go ahead and implement myself.
How do I test all this?
I really like the Testing Trophy approach. It’s geared towards frontend but has some easily adapted definitions that we can use to categorise our tests.
Fastify comes with http injection which is great for testing your request handlers. Consider this example where ‘status’ is used to filter customers based on some mocked data:
const response = await app.inject({
method: 'get',
url: '/customers',
query: {
status: 'unverified'
}
});
expect(response.statusCode).toBe(200);
I tend to think of my handlers as black boxes since all I’m really interested in is that they respond correctly with the given inputs.
For end to end testing you could utilise the test.each
method in Jest. This is a quick and dirty way to get off the ground and is particularly beneficial when transitioning from tests created in Postman/Newman. In essence, you compose your specifications in a JSON format that you can use with your choice of request library:
import axios, { AxiosRequestConfig } from 'axios';
type Spec = {
name: string;
expectedStatusCode: number;
} & AxiosRequestConfig
type Endpoint = {
description: string;
specs: Spec[];
};
const endpoints: Endpoint[] = [
{
description: '/customers',
specs: [{
name: 'success',
method: 'get',
url: '/customers',
expectedStatusCode: 200
}],
}
];
You can then run this through a loop and hand over to Jest:
// e2e.spec.ts
for (const endpoint of endpoints) {
describe(endpoint.description, () => {
test.each(endpoint.specs)('$name', async ({ schema, expectedStatusCode, ...params }) => {
const res = await axios.request(params);
expect(res.status).toBe(expectedStatusCode);
});
});
}
If your tests aren’t super complex and you just need to check a few things, this method might be all you need. Also, keep in mind that with Fastify, you automatically get checks on your response data. So, it might be enough just to check the status code.
Conclusion
This was a brief tour of why I think Fastify is a great choice for developing microservices and how I’ve used it. I’ve thrown together a more concrete example here which demonstrates the ideas I’ve discussed so far. Happy coding!