Loading route schemas
Before implementing the schemas, let’s add a dedicated folder to organize our code base better. We can do it inside the ./routes/todos/
path. Moreover, we want to load them automatically from the schemas
folder. To be able to do that, we need the following:
- A dedicated plugin inside the
schemas
folder - A definition of the schemas we wish to use
- An autohooks plugin that will load everything automatically when the
todos
module is registered on the Fastify instance
We will discuss these in detail in the following subsections.
Schemas loader
Starting with the first item of the list we just discussed, we want to create a ./routes/todos/schemas/loader.js
file. We can check the content of the file in the following code snippet:
'use strict' const fp = require('fastify-plugin') module.exports = fp(async function schemaLoaderPlugin (fastify, opts) { // [1] fastify.addSchema(require('./list-query.json')) // [2] fastify.addSchema(require('./create-body.json')) fastify.addSchema(require('./create-response.json')) fastify.addSchema(require('./status-params.json')) })
Let’s break down this simple plugin:
- We defined a Fastify plugin named
schemaLoaderPlugin
that loads JSON schemas ([1]) - We called Fastify’s
addSchema
method several times, passing the path of each JSON file as an argument ([2])
As we already know, every schema definition defines the structure and the validation rules of response bodies, parameters, and queries for different routes.
Now, we can start implementing the first body validation schema.
Validating the createTodo request body
The application will use this schema during task creation. We want to achieve two things with this schema:
- Prevent users from adding unknown properties to the entity
- Make the
title
property mandatory for every task
Let’s take a look at the code of create-body.json
:
{ "type": "object", "$id": "schema:todo:create:body", // [1] "required": ["title"], // [2] "additionalProperties": false, // [3] "properties": { "title": { "type": "string" // [4] } } }
The schema is of the object
type and, even if short in length, adds many constraints to the allowed inputs:
$id
is used to identify the schema uniquely across the whole application; it can be used to reference it in other parts of the code ([1]).- The
required
keyword specifies that thetitle
property is required for this schema. Any object that does not contain it will not be considered valid against this schema ([2]). - The
additionalProperties
keyword isfalse
([3]), meaning that any properties not defined in the"properties"
object will be considered invalid against this schema and discarded. - The only property allowed is
title
of thestring
type ([4]). The validator will try to converttitle
to a string during the body validation phase.
Inside Using the schemas section, we will see how to attach this definition to the correct route. Now, we will move on and secure the request path parameters.
Validating the changeStatus request parameters
This time, we want to validate the request path parameters instead of a request body. This will allow us to be sure that the call contains the correct parameters with the correct type. The following status-params.json
shows the implementation:
{ "type": "object", "$id": "schema:todo:status:params", // [1] "required": ["id", "status"], // [2] "additionalProperties": false, "properties": { "id": { "type": "string" // [3] }, "status": { "type": "string", "enum": ["done", "undone"] // [4] } } }
Let’s take a look at how this schema works:
- The
$id
field defines another unique identifier for this schema ([1]). - In this case, we have two required parameters –
id
andstatus
([2]). - The
id
property must be a string ([3]), whilestatus
is a string whose value can be"done"
or"undone"
([4]). No other properties are allowed.
Next, we will explore how to validate the query parameters of a request using listTodos
as an example.
Validating the listTodos request query
At this point, it should be clear that all schemas follow the same rules. A query schema is not an exception. However, in the list-query.json
snippet, we will use schema reference for the first time:
{ "type": "object", "$id": "schema:todo:list:query", // [1] "additionalProperties": false, "properties": { "title": { "type": "string" // [2] }, "limit": { "$ref": "schema:limit#/properties/limit" // [3] }, "skip": { "$ref": "schema:skip#/properties/skip" } } }
We can now break down the snippet:
- As usual, the
$id
property gives the schema a unique identifier that can be referenced elsewhere in the code ([1]). - The
title
property is of thestring
type, and it is optional ([2]). It can be filtered by the partialtitle
of the to-do item. If not passed, the filter will be created empty. - The
limit
property specifies the maximum number of items to return and is defined by referencing theschema schema:limit
schema ([3]). Theskip
property is also defined by referencingschema schema:skip
and is used for pagination purposes. These schemas are so general that they are shared throughout the project.
Now, it is time to take a look at the last schema type – the response schema.
Defining the createTodo response body
Defining a response body of a route adds two main benefits:
- It prevents us from leaking undesired information to clients
- It increases the throughput of the application, thanks to the faster serialization
create-response.json
illustrates the implementation:
{ "type": "object", "$id": "schema:todo:create:response", // [1] "required": ["id"], // [2] "additionalProperties": false, "properties": { "id": { "type": "string" // [3] } } }
Let’s examine the structure of this schema:
- Once again,
$id
is a unique identifier for this schema ([1]) - The response object has one
required
([2]) property, namedid
, of thestring
type ([3])
This response schema ends the current section about schema definitions. Now, it is time to learn how to use and register those schemas.
Adding the Autohooks plugin
Once again, we can leverage the extensibility and the plugin system Fastify gives to developers. We can start by recalling from Chapter 6 that we already registered a @fastify/autoload
instance on our application. The following excerpt from the app.js
file shows the relevant parts:
fastify.register(AutoLoad, { dir: path.join(__dirname, 'routes'), indexPattern: /.*routes(\.js|\.cjs)$/i, ignorePattern: /.*\.js/, autoHooksPattern: /.*hooks(\.js|\.cjs)$/i, // [1] autoHooks: true, // [2] cascadeHooks: true, // [3] options: Object.assign({}, opts) })
For the purpose of this section, there are three properties that we care about:
autoHooksPattern
([1]) is used to specify a regular expression pattern that matches the filenames of the hook files in theroutes
directory. These files will be automatically loaded and registered as hooks for the corresponding routes.autoHooks
([2]) enables the automatic loading of those hook files.cascadeHooks
([3]) ensures that the hooks are executed in the correct order.
After this brief reminder, we can move on to implementing our autohook plugin.
Implementing the Autohook plugin
We learned from autoHooksPattern
in the previous section that we can put our plugin inside a file named autohooks.js
in the ./routes/todos
directory, and it will be automatically registered by @fastify/autoload
. The following snippet contains the content of the plugin:
'use strict' const fp = require('fastify-plugin') const schemas = require('./schemas/loader') // [1] module.exports = fp(async function todoAutoHooks (fastify, opts) { fastify.register(schemas) // [2] })
We start importing the schema loader plugin we defined in a previous section ([1]). Then, inside the plugin body, we register it ([2]). This one line is enough to make the loaded schemas available in the application. In fact, the plugin attaches them to the Fastify instances to make them easily accessible.
Finally, we can use these schemas inside our route definitions, which we will do in the next section.
Using the schemas
Now, we have everything in place to secure our routes and make the application’s throughput ludicrously fast.
This section will only show you how to do it for one route handler. You will find the complete code in the book’s repository at https://github.com/PacktPublishing/Accelerating-Server-Side-Development-with-Fastify/tree/main/Chapter%207, and you are encouraged to experiment with other routes too.
The following code snippet attaches the schemas to the route definition:
fastify.route({ method: 'POST', url: '/', schema: { body: fastify.getSchema('schema:todo:create:body'), // [1] response: { 201: fastify.getSchema('schema:todo:create:response') // [2] } }, handler: async function createTodo (request, reply) { // ...omitted for brevity } })
We are adding a schema
property to the route definition. It contains an object with two fields:
- The
body
property of theschema
option specifies the JSON schema that the request body must validate against ([1]). Here, we usefastify.getSchema
('schema:todo:create:body')
, which retrieves the JSON schema for the request body from the schemas collection, using the ID we specified in the declaration. - The
response
property of theschema
option specifies the JSON schema for the response to the client ([2]). It is set to an object with a single key,201
, which specifies the JSON schema for a successful creation response, since it is the code we used inside the handler. Again, we usefastify.getSchema('schema:todo:create:response')
to retrieve the JSON schema for the response from the schemas collection.
If we now try to pass an unknown property, the schema validator will strip away from the body. Let’s experiment with it using the terminal and curl
:
$ curl -X POST http://localhost:3000/todos -H "Content-Type: application/json" -d '{"title": "awesome task", "foo": "bar"}' {"id":"6418671d625e3ba28a056013"}% $ curl http://localhost:3000/todos/6418671d625e3ba28a056013 {"id":"6418671d625e3ba28a056013","title":"awesome task","done":false,"createdAt":"2023-03-20T14:01:01.658Z","modifiedAt":"2023-03-20T14:01:01.658Z"}%
We pass the foo
property inside our body, and the API returns a successful response, with the unique id
of the task saved in the database. The second call checks that the validator works as expected. The foo
field isn’t present in the resource, and therefore, it means that our API is now secure.
This almost completes our deep dive into RESTful API development with Fastify. However, there is one more important thing that can make our code base more maintainable, which we need to mention before moving on.