How to Use Schemas on Fastify for Fun and Profit

10 Dec 20208 min read • 3.9k

This year, Fastify became my go-to framework for building Node.js APIs.

If the word sounds new to you, Fastify is a web framework for Node.js. It is used to build APIs and services the same way Express does.

Fastify comes with great features that really speed up the process of making applications. Among those features, my favorite one is the fact that the framework is schema-based (I'll explain).

In this post, I will share a few tricks on how you can leverage Fastify's schema capabilities to build APIs at a fast clip.

Schemas

Fastify adopts the JSON Schema format on its core. Many of its features and libraries are built around the popular standard. Ajv, a library to compile and validate JSON Schemas, is a direct dependency of the framework.

By adopting JSON Schema, Fastify opens doors to an entire ecosystem of tools built around it. Below, let's see how to combine all these tools and libraries together with the framework.

1. Validation

One of the ways Fastify uses JSON Schema is to validate data coming from clients. It lets you add input schemas to your routes. For example:

// Schema for `POST /movie` body
const PostMovieBody = {
  type: 'object',
  properties: {
    title: { type: 'string' },
    releaseYear: { type: 'integer', minimum: 1878 },
  },
}

app.post('/movie', {
  schema: {
    // Refence the schema here
    body: PostMovieBody,
  },
  handler: createMovie,
})
1. Using input schemas to validate data in the router.

In this example, any incoming data to POST /movie that doesn't conform to the PostMovieBody schema will throw a validation error.

This way, we are making sure that the handler function doesn't process any invalid or unexpected payloads.

Invalid objects will result in a validation error that looks like this:

POST /movie
{ releaseYear: 2020 } # The `title` parameter was not sent

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body should have required property 'title'"
}
2. Example of Fastify's default error response.

Note: the content on message comes from Ajv.

2. Serialization

Serialization is the process of converting an object to a format that can be transferred over a network.

With Fastify, you can also define output schemas for JSON payloads. When you do so, any data returned to clients will be serialized and validated according to that definition.

More specifically, defining output schemas helps you in two ways:

  • Fastify serializes the data with fast-json-stringify. In many cases, it is faster than JSON.stringify.
  • Ajv validates the response. This will prevent sensitive fields from being exposed.

When declaring output schemas in your routes, each possible status code accepts a definition. For example, you can have schemas defined for 200 and 204 responses.

Tip: to use the same schema for a family of status codes, you can use the '2xx' notation.

Here's how to define an output schema to responses with a 200 status code:

// Generic `Movie` schema
const Movie = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    title: { type: 'string' },
    releaseYear: { type: 'integer', minimum: 1878 },
  },
}

app.post('/movie', {
  schema: {
    response: {
      // Payloads will be serialized according to the `Movie` schema
      200: Movie,
    },
  },
  // ...
})
3. Using output schemas to serialize and validate data in the router.

In this example, any object returned by the handler that doesn't match the Movie schema will result in an error. By default, the client receives a 400 response - similar to the example #2.

3. Documentation

Documentation is an essential piece in any REST API.

There are many ways to document your application. One of them is manually, where you write routes and definitions by hand in a common format like YAML or JSON.

You can already guess this approach has many problems: outdated schemas, inconsistent validations, type discrepancies, etc.

Another approach is automating your documentation. A tool will automatically generate all the routes and definitions based on an existing schema.

One popular specification for writing documentation is Swagger. Thanks to the official fastify-swagger plugin, you can transform your existing JSON Schema definitions into Swagger ones and expose a beautiful documentation page in a flick.

Adding fastify-swagger to a Fastify application should be straightforward:

const fastify = require('fastify')()

// Register the plugin before your routes
fastify.register(require('fastify-swagger'), {
  exposeRoute: true,
  routePrefix: '/documentation',
  swagger: {
    info: { title: 'movie-api' },
    // Add more options to get a nicer page ✨
  },
})

// Declare your routes here...
4. Registering the `fastify-swagger` plugin.

Now, when you start your Fastify application and navigate to /documentation in a browser this page will pop up:

5. The documentation page generated by `fastify-swagger`.

Note: The more metadata you add to your routes, the more complete your page will look. Check out the route options documentation.

4. Mocking

When testing services or endpoints, many times you will need to provide a fake or simulated input. These inputs are called mock objects. They replicate the structure and behavior of real objects.

You can create mock objects dynamically with the schemas you already have by using json-schema-faker. The library converts existing JSON Schemas into dummy objects that you can use in your tests. Let's see an example.

First, create a helper function (just a wrapper for json-schema-faker):

const jsf = require('json-schema-faker')

/**
 * Creates an object from a JSON Schema. Example:
 * schemaToObject(Movie)
 * => { id: 823, title: 'unicorn', releaseYear: 1942 }
 */
function schemaToObject(schema) {
  return jsf.resolve(schema)
}
6. Creating the `schemaToObject` helper function.

The schemaToObject function does exactly what the name says: given a JSON Schema definition, it returns a matching mock object.

Now let's put it to use. You can call this function whenever you need to create fake objects for your tests. For example, when sending requests to routes:

it('should create a movie', async () =
	// Create a mock object for the request
	const payload = await schemaToObject(PostMovieBody)

	// Calls the POST /movie
	const response = await request.post('/movie', payload)

	expect(response.status).toBe(200)
})
7. Using `schemaToObject` to generate fake payloads.

In this example, we are creating a mock object, POST-ing it to the POST /movie route, and checking the status code.

The schemaToObject function gives you a nice and clean way to test the "happy path" in your tests (when everything meets the expectations).

5. Jest

Jest is a testing framework for JavaScript. One of its features is the possibility to create or import custom matchers.

One of these matchers is jest-json-schema. This package adds a new assertion to Jest: toMatchSchema. It lets you validate an object against an existing JSON Schema definition - it's like Ajv was integrated to Jest.

Note: If you are using another testing framework, there is likely an equivalent to jest-json-schema for it.

Instead of manually asserting the values of each property in an object like this:

it('should create a movie', async () => {
  // ...
  expect(response.title).toBeString()
  expect(response.releaseYear).toBePositive()
})
8. Manually asserting each property in an object.

You can simplify things using toMatchSchema:

import { matchers } from 'jest-json-schema'
import { Movie } from './schemas'

expect.extend(matchers)

it('should create a movie', async () => {
  // ...
  expect(response).toMatchSchema(Movie)
})
9. Asserting all properties using a schema.

Notice I am using the Movie schema defined in example #3.

Of course, this is just simplifying type-checking in your tests. There are still other aspects of your code that need to be tested. Still, based on how easy it is to implement, I believe it's a good addition.

Putting it all together

Let's do a quick recap.

In examples #1 and #3, we have declared two schemas using the JSON Schema format - PostMovieBody and Movie. These schemas are used for:

  1. Validating objects sent to the route.
  2. Serializing and validating objects returned to the clients.
  3. Generating documentation.
  4. Creating mock objects.
  5. Asserting objects on tests.

Now here's the fun part!

Suppose you need to start tracking a new property in your movie objects. For example, you need to save and display the movie poster URL. Let's name the new field posterUrl.

If you were not using a schema-based framework, you would need to go through all your code and update the existing objects to include the new property. This is far from ideal. The chances of missing an assertion in your tests or forgetting to update the documentation are high.

But thanks to the magic of schemas, this process is a breeze. Your definitions are your source of truth. Anything based on the schemas will change once the schema changes.

So, now let's see how we can add the posterUrl property.

The first step is to change the input schema (PostMovieBody) to include the new property:

const PostMovieBody = {
  type: 'object',
  properties: {
    title: { type: 'string' },
    releaseYear: { type: 'integer', minimum: 1878 },
+   posterUrl: { type: 'string' }
  }
}
10. Adding `posterUrl` to the input schema.

Now, since posterUrl must also be serialized and returned to the client, we also add it to the output schema (Movie):

const Movie = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    title: { type: 'string' },
    releaseYear: { type: 'integer', minimum: 1878 }
+   posterUrl: { type: 'string' }
  }
}
11. Adding `posterUrl` to the output schema.

And that's pretty much it!

Here is what will happen once you restart your server:

  1. Fastify will start checking for posterUrl in the POST /movie route.
  2. The Swagger file will be updated. The posterUrl property will start showing on the documentation page.
  3. Mock objects in your tests will start being generated with a string value for posterUrl.
  4. Tests using the toMatchSchema matcher will start checking for the posterUrl property.

...and you got all that just by changing two lines in your code. How cool is that?

Honorable mention: fluent-schema

If you are used to libraries like Joi or Yup, writing schemas using raw JavaScript objects might feel like a step back.

To overcome that feeling, you can use fluent-schema. It gives you the same compact and programmable interface present in other tools.

For example, we could rewrite the Movie schema in example #3 using fluent-schema:

const S = require('fluent-schema')

const Movie = const schema = S.object()
  .prop('title', S.string())
  .prop('releaseYear', S.number().minimum(1878))
12. Example of the `Movie` schema defined with fluent-schema.

Looks neat, huh?

And that's a wrap! I hope you have enjoyed it. Stay tuned for more Fastify articles. ✌️

◆◆

Ruan Martinelli

Hi! Ruan here, I write this blog. Here you will find articles and tutorials about JavaScript, Node.js, and tools like Terraform.

Follow me on Twitter for new articles and updates.