DEV Community

Cristian David Ippolito Carrillo
Cristian David Ippolito Carrillo

Posted on • Edited on

Demo API using fastify

Introduction

I was looking for new technologies and new good things to learn and that's how I get to know about fastify.
At first look, it's a framework quite similar to Express, but, it has better benchmarks and some pretty good features, so I decided to try it and that's why this post.

This post was updated and the code now its working with fastify 3.x

All code about this post is here.

let's start

Project structure:

./migrations
./src
    /plugins
    /routes
        /api
            /persons
                /index.js
                /schemas.js
            /products
                /index.js
                /schemas.js
        /docs
            /index.js
    /services
        /persons.js
        /products.js
    /utils
        /functions.js
    /app.js
    /environment.js
    /start.js
./knexfile.js
Enter fullscreen mode Exit fullscreen mode

./migrations:
In this case I'm using the knex query builder, for me it’s quite fine and working with migrations and seeds is easy. I just have one migration file for the Product (20200124230315_create_Person_table.js) table and looks like:

const up = knex => {
  return knex.schema.hasTable('Person').then(exists => {
    if (!exists) {
      return knex.schema.createTable('Person', table => {
        table.increments('id');
        table.string('name', 100).notNullable();
        table.string('lastName', 100).defaultTo(null);
        table.string('document', 15).notNullable();
        table.string('genre', 1).notNullable();
        table.integer('phone').unsigned().notNullable();
        table.timestamps(true, true);

        table.unique('document');
      });
    }
  });
};

const down = knex => {
  return knex.schema.dropTable('Person');
};

module.exports = {
  up,
  down
};
Enter fullscreen mode Exit fullscreen mode

Note: For a quick start about the migrations or seeds, you can see this gist.

./src/plugins:
An important aspect at fastify is the plugin concept, it’s helpful to encapsulate code functionalities and then use them at fastify app object. I have two plugins one for knex connection and one for mongo connection.

  • ./src/plugins/knex-db-connector.js:
const fastifyPlugin = require('fastify-plugin');
const knex = require('knex');

const {
  DB_SQL_CLIENT,
  DB_SQL_HOST,
  DB_SQL_USER,
  DB_SQL_PASSWORD,
  DB_SQL_NAME,
  DB_SQL_PORT
} = require('../environment');

const knexConnector = async (server, options = {}) => {
  const db = knex({
    client: DB_SQL_CLIENT,
    connection: {
      host: DB_SQL_HOST,
      user: DB_SQL_USER,
      password: DB_SQL_PASSWORD,
      database: DB_SQL_NAME,
      port: DB_SQL_PORT,
      ...options.connection
    },
    ...options
  });
  server.decorate('knex', db);
};

// Wrapping a plugin function with fastify-plugin exposes the decorators,
// hooks, and middlewares declared inside the plugin to the parent scope.
module.exports = fastifyPlugin(knexConnector);
Enter fullscreen mode Exit fullscreen mode
  • ./src/plugins/mongo-db-connector.js:
const {
  DB_NOSQL_USER,
  DB_NOSQL_PASSWORD,
  DB_NOSQL_HOST,
  DB_NOSQL_NAME
} = require('../environment');

const MONGO_URL = `mongodb+srv://${DB_NOSQL_USER}:${DB_NOSQL_PASSWORD}@${DB_NOSQL_HOST}/${DB_NOSQL_NAME}?retryWrites=true&w=majority`;

const mongoConnector = app => {
  app.register(require('fastify-mongodb'), {
    // force to close the mongodb connection when app stopped
    // the default value is false
    forceClose: true,
    url: MONGO_URL
  });
};

module.exports = mongoConnector;
Enter fullscreen mode Exit fullscreen mode

Note: In this case I'm using fastify-mongodb to help me with the connection. At this moment it's throwing a warning about some deprecated methods, that's a problem that will have a solution on comming updates from fastify-mongodb.

./src/routes:
Like in Express, the routes in fastify have a route handler with the request object and the response object (reply for fastify), but in fastify, the routes can have some other features like validation and serialization using JSON Schema.

  • ./src/routes/api/index.js:
const oas = require('fastify-swagger');

const apiRoutes = async (app, options) => {
  app.register(oas, require('../docs'));
  app.register(require('./persons'), { prefix: 'persons' });
  app.register(require('./products'), { prefix: 'products' });
  app.get('/', async (request, reply) => {
    return { hello: 'world' };
  });
};

module.exports = apiRoutes;
Enter fullscreen mode Exit fullscreen mode

Note: As you can see I have a GET route ‘/’ and it has a route handler, also I’m using as plugins the other routes for persons and products endpoints, I describe them below, and finally I’m using fastify-swagger, don’t worry I’m going to explain that.

  • ./src/routes/api/persons/index.js:
const { PersonService } = require('../../../services/persons');
const { createSchema, getAllSchema, getOneSchema, updateSchema, deleteSchema } = require('./schemas');

const personRoutes = async (app, options) => {
  const personService = new PersonService(app);

  // create
  app.post('/', { schema: createSchema }, async (request, reply) => {
    const { body } = request;

    const created = await personService.create({ person: body });

    return created;
  });

  // get all
  app.get('/', { schema: getAllSchema }, async (request, reply) => {
    app.log.info('request.query', request.query);
    const persons = await personService.getAll({});
    return persons;
  });

  // get one
  app.get('/:personId', { schema: getOneSchema }, async (request, reply) => {
    const { params: { personId } } = request;

    app.log.info('personId', personId);

    const person = await personService.getOne({ id: personId });
    return person;
  });

  // update
  app.patch('/:personId', { schema: updateSchema }, async (request, reply) => {
    const { params: { personId } } = request;

    const { body } = request;

    app.log.info('personId', personId);
    app.log.info('body', body);

    const updated = await personService.update({ id: personId, person: body });

    return updated;
  });

  // delete
  app.delete('/:personId', { schema: deleteSchema }, async (request, reply) => {
    const { params: { personId } } = request;

    app.log.info('personId', personId);

    const deleted = await personService.delete({ id: personId });
    return deleted;
  });
};

module.exports = personRoutes;
Enter fullscreen mode Exit fullscreen mode

Note: Here I have all the endpoints related to persons, I’m using the schema for each endpoint and also I’m using the PersonService, I'll explain that.

  • ./src/routes/api/persons/schemas.js:
   const personProperties = {
  id: { type: 'number' },
  name: { type: 'string' },
  lastName: { type: 'string', nullable: true },
  document: { type: 'string' },
  genre: {
    type: 'string',
    enum: ['M', 'F']
  },
  phone: { type: 'number', maximum: 9999999999 },
  created_at: { type: 'string' },
  updated_at: { type: 'string' }
};

const tags = ['person'];

const paramsJsonSchema = {
  type: 'object',
  properties: {
    personId: { type: 'number' }
  },
  required: ['personId']
};

const queryStringJsonSchema = {
  type: 'object',
  properties: {
    filter: { type: 'string' }
  },
  required: ['filter']
};

const bodyCreateJsonSchema = {
  type: 'object',
  properties: personProperties,
  required: ['name', 'document', 'genre', 'phone']
};

const bodyUpdateJsonSchema = {
  type: 'object',
  properties: personProperties
};

const getAllSchema = {
  tags,
  querystring: queryStringJsonSchema,
  response: {
    200: {
      type: 'array',
      items: {
        type: 'object',
        properties: personProperties
      }
    }
  }
};

const getOneSchema = {
  tags,
  params: paramsJsonSchema,
  querystring: queryStringJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: personProperties
    }
  }
};

const createSchema = {
  tags,
  body: bodyCreateJsonSchema,
  response: {
    201: {
      type: 'object',
      properties: personProperties
    }
  }
};

const updateSchema = {
  tags,
  params: paramsJsonSchema,
  body: bodyUpdateJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: personProperties
    }
  }
};

const deleteSchema = {
  tags,
  params: paramsJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: personProperties
    }
  }
};

module.exports = {
  getAllSchema,
  getOneSchema,
  createSchema,
  updateSchema,
  deleteSchema
};
Enter fullscreen mode Exit fullscreen mode

Note: Here are all the schemas for each endpoint, using these schemas I can have some importans validations, for example, a body attribute can't be a string if was defined as number, the maximun length of a string attribute, etc.

  • ./src/routes/api/products/index.js:
const { ProductService } = require('../../../services/products');
const {
  createSchema,
  getAllSchema,
  getOneSchema,
  updateSchema,
  deleteSchema
} = require('./schemas');

const productRoutes = async (app, options) => {
  const productService = new ProductService(app);

  // create
  app.post('/', { schema: createSchema }, async (request, reply) => {
    const { body } = request;

    const insertedId = await productService.create({ product: body });
    app.log.info('insertedId', insertedId);
    return { _id: insertedId };
  });

  // get all
  app.get('/', { schema: getAllSchema }, async (request, reply) => {
    app.log.info('request.query', request.query);
    const products = await productService.getAll({ filter: {} });
    return products;
  });

  // get one
  app.get('/:productId', { schema: getOneSchema }, async (request, reply) => {
    const { params: { productId } } = request;

    app.log.info('productId', productId);

    const product = await productService.getOne({ id: productId });

    return product;
  });

  // update
  app.patch('/:productId', { schema: updateSchema }, async (request, reply) => {
    const { params: { productId } } = request;

    const { body } = request;

    app.log.info('productId', productId);
    app.log.info('body', body);

    const updated = await productService.update({ id: productId, product: body });

    return updated;
  });

  // delete
  app.delete('/:productId', { schema: deleteSchema }, async (request, reply) => {
    const { params: { productId } } = request;

    app.log.info('productId', productId);

    const deleted = await productService.delete({ id: productId });

    return deleted;
  });
};

module.exports = productRoutes;
Enter fullscreen mode Exit fullscreen mode
  • ./src/routes/api/products/schemas.js:
const productProperties = {
  _id: { type: 'string' },
  name: { type: 'string' },
  description: { type: 'string' },
  image: { type: 'string', nullable: true },
  price: { type: 'number', maximum: 9999999999 }
};

const tags = ['product'];

const paramsJsonSchema = {
  type: 'object',
  properties: {
    productId: { type: 'string' }
  },
  required: ['productId']
};

const queryStringJsonSchema = {
  type: 'object',
  properties: {
    filter: { type: 'string' }
  },
  required: ['filter']
};

const bodyCreateJsonSchema = {
  type: 'object',
  properties: productProperties,
  required: ['name', 'description', 'price']
};

const bodyUpdateJsonSchema = {
  type: 'object',
  properties: productProperties
};

const getAllSchema = {
  tags,
  querystring: queryStringJsonSchema,
  response: {
    200: {
      type: 'array',
      items: {
        type: 'object',
        properties: productProperties
      }
    }
  }
};

const getOneSchema = {
  tags,
  params: paramsJsonSchema,
  querystring: queryStringJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: productProperties
    }
  }
};

const createSchema = {
  tags,
  body: bodyCreateJsonSchema,
  response: {
    201: {
      type: 'object',
      properties: productProperties
    }
  }
};

const updateSchema = {
  tags,
  params: paramsJsonSchema,
  body: bodyUpdateJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: productProperties
    }
  }
};

const deleteSchema = {
  tags,
  params: paramsJsonSchema,
  response: {
    200: {
      type: 'object',
      properties: productProperties
    }
  }
};

module.exports = {
  getAllSchema,
  getOneSchema,
  createSchema,
  updateSchema,
  deleteSchema
};
Enter fullscreen mode Exit fullscreen mode
  • ./src/routes/docs/index.js:
const { APP_PORT } = require('../../environment');

module.exports = {
  routePrefix: '/documentation',
  exposeRoute: true,
  swagger: {
    info: {
      title: 'fastify demo api',
      description: 'docs',
      version: '0.1.0'
    },
    externalDocs: {
      url: 'https://swagger.io',
      description: 'Find more info here'
    },
    servers: [
      { url: `http://localhost:${APP_PORT}`, description: 'local development' },
      { url: 'https://dev.your-site.com', description: 'development' },
      { url: 'https://sta.your-site.com', description: 'staging' },
      { url: 'https://pro.your-site.com', description: 'production' }
    ],
    schemes: ['http'],
    consumes: ['application/json'],
    produces: ['application/json'],
    tags: [
      { name: 'person', description: 'Person related end-points' },
      { name: 'product', description: 'Product related end-points' }
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

Note: Is the turn to talk about fastify-swagger, It's a module that helps us creating the API documentation, using this “configuration file” and the schemas used before, for each endpoint.

./src/services:
This folder contains the “classes” in charge to resolve all the logic needed from the endpoints.

  • ./src/services/persons.js:
const { isEmptyObject } = require('../utils/functions');

class PersonService {
  /**
   * Creates an instance of PersonService.
   * @param {object} app fastify app
   * @memberof PersonService
   */
  constructor (app) {
    if (!app.ready) throw new Error(`can't get .ready from fastify app.`);
    this.app = app;

    const { knex } = this.app;

    if (!knex) {
      throw new Error('cant get .knex from fastify app.');
    }
  }

  /**
   * function to create one
   *
   * @param { {person: object} } { person }
   * @returns {Promise<number>} created id
   * @memberof PersonService
   */
  async create ({ person }) {
    const err = new Error();
    if (!person) {
      err.statusCode = 400;
      err.message = 'person is needed.';
      throw err;
    }

    const { knex } = this.app;

    const id = (await knex('Person').insert(person))[0];

    const createdPerson = await this.getOne({ id });

    return createdPerson;
  }

  /**
   * function to get all
   *
   * @param { filter: object } { filter = {} }
   * @returns {Promise<{ id: number }>[]} array
   * @memberof PersonService
   */
  async getAll ({ filter = {} }) {
    const { knex } = this.app;

    const persons = await knex.select('*').from('Person').where(filter);

    return persons;
  }

  /**
   * function to get one
   *
   * @param { { id: number } } { id }
   * @returns {Promise<{id: number}>} object
   * @memberof PersonService
   */
  async getOne ({ id }) {
    const err = new Error();

    if (!id) {
      err.message = 'id is needed';
      err.statusCode = 400;
      throw err;
    }

    const { knex } = this.app;

    const data = await knex.select('*').from('Person').where({ id });

    if (!data.length) {
      err.statusCode = 412;
      err.message = `can't get the person ${id}.`;
      throw err;
    }

    const [person] = data;
    return person;
  }

  /**
   * function to update one
   *
   * @param { { id: number, person: object } } { id, person = {} }
   * @returns {Promise<{ id: number }>} updated
   * @memberof PersonService
   */
  async update ({ id, person = {} }) {
    const personBefore = await this.getOne({ id });

    if (isEmptyObject(person)) {
      return personBefore;
    }

    const { knex } = this.app;
    await knex('Person')
      .update(person)
      .where({ id: personBefore.id });

    const personAfter = await this.getOne({ id });

    return personAfter;
  }

  /**
   * function to delete one
   *
   * @param { { id: number } } { id }
   * @returns {Promise<object>} deleted
   * @memberof PersonService
   */
  async delete ({ id }) {
    const personBefore = await this.getOne({ id });

    const { knex } = this.app;
    await knex('Person').where({ id }).delete();

    delete personBefore.id;

    return personBefore;
  }
}

module.exports = {
  PersonService
};
Enter fullscreen mode Exit fullscreen mode

Note: As you noticed, this is the service that I’m using in the person routes, by constructor I'm passing the fastify app and then, from the fastify app I get the knex connector to keep in touch with the database.

  • ./src/services/products.js:
const { ObjectId } = require('mongodb');

class ProductService {
  /**
   * Creates an instance of ProductService.
   * @param {object} app fastify app
   * @memberof ProductService
   */
  constructor (app) {
    if (!app.ready) throw new Error(`can't get .ready from fastify app.`);
    this.app = app;
    const { mongo } = this.app;

    if (!mongo) {
      throw new Error('cant get .mongo from fastify app.');
    }

    const db = mongo.db;
    const collection = db.collection('Product');
    this.collection = collection;
  }

  /**
   * function to create one
   *
   * @param {{ product: object }} { product }
   * @returns {Promise<{ id: number }>} created
   * @memberof ProductService
   */
  async create ({ product }) {
    const { insertedId } = (await this.collection.insertOne(product));

    const created = await this.getOne({ id: insertedId });

    return created;
  }

  /**
   * function to get all
   *
   * @param {{ filter: object }} { filter = {} }
   * @returns {Promise<{ id: number }> []} array
   * @memberof ProductService
   */
  async getAll ({ filter = {} }) {
    const products = await this.collection.find(filter).toArray();

    return products;
  }

  /**
   * function to get one
   *
   * @param {{ id: number }} { id }
   * @returns {Promise<{ id: number }>}
   * @memberof ProductService
   */
  async getOne ({ id }) {
    const err = new Error();

    if (!id) {
      err.statusCode = 400;
      err.message = 'id is needed.';
      throw err;
    }

    const product = await this.collection.findOne({ _id: ObjectId(id) });

    if (!product) {
      err.statusCode = 400;
      err.message = `can't get the product ${id}.`;
      throw err;
    }

    return product;
  }

  /**
   * function to update one
   *
   * @param {{ id: number, product: object }} { id, product }
   * @returns {Promise<{ id: number }>} updated
   * @memberof ProductService
   */
  async update ({ id, product }) {
    await this.getOne({ id });

    const { upsertedId } = (await this.collection.updateOne(
      {
        _id: ObjectId(id)
      },
      {
        $set: product
      },
      {
        upsert: true
      }
    ));

    const after = await this.getOne({ upsertedId });

    return after;
  }

  /**
   * function to delete one
   *
   * @param {{ id: number }} { id }
   * @returns {Promise<object>} deleted
   * @memberof ProductService
   */
  async delete ({ id }) {
    const before = await this.getOne({ id });

    await this.collection.deleteOne({ _id: ObjectId(id) });

    delete before._id;

    return before;
  }
}

module.exports = {
  ProductService
};
Enter fullscreen mode Exit fullscreen mode

Note: In the same way, as in the PersonService I’m passing the fastify app by the constructor and then I use it to get the mongo connector.

./src/environment.js:
This is a file that I use to handle the environment vars depending on the context (local, development, staging, production), and looks like:

const dotenv = require('dotenv');
const path = require('path');

dotenv.config({ path: path.resolve(__dirname, '../.env') });

let envPath;

// validate the NODE_ENV
const NODE_ENV = process.env.NODE_ENV;
switch (NODE_ENV) {
case 'development':
  envPath = path.resolve(__dirname, '../.env.development');
  break;
case 'staging':
  envPath = path.resolve(__dirname, '../.env.staging');
  break;
case 'production':
  envPath = path.resolve(__dirname, '../.env.production');
  break;
default:
  envPath = path.resolve(__dirname, '../.env.local');
  break;
};

dotenv.config({ path: envPath });

const enviroment = {
  /* GENERAL */
  NODE_ENV,
  TIME_ZONE: process.env.TIME_ZONE,
  APP_PORT: process.env.APP_PORT || 8080,
  /* DATABASE INFORMATION */
  DB_NOSQL_HOST: process.env.DB_NOSQL_HOST,
  DB_NOSQL_USER: process.env.DB_NOSQL_USER,
  DB_NOSQL_PASSWORD: process.env.DB_NOSQL_PASSWORD,
  DB_NOSQL_NAME: process.env.DB_NOSQL_NAME,
  DB_NOSQL_PORT: process.env.DB_NOSQL_PORT,
  DB_SQL_CLIENT: process.env.DB_SQL_CLIENT,
  DB_SQL_HOST: process.env.DB_SQL_HOST,
  DB_SQL_USER: process.env.DB_SQL_USER,
  DB_SQL_PASSWORD: process.env.DB_SQL_PASSWORD,
  DB_SQL_NAME: process.env.DB_SQL_NAME,
  DB_SQL_PORT: process.env.DB_SQL_PORT
};

module.exports = enviroment;
Enter fullscreen mode Exit fullscreen mode

./src/app.js:
Now we are in the most important file in this API demo, this file has the config/setup of the project.

const Fastify = require('fastify');
const cors = require('cors');

// order to register / load
// 1. plugins (from the Fastify ecosystem)
// 2. your plugins (your custom plugins)
// 3. decorators
// 4. hooks and middlewares
// 5. your services

const build = async () => {
  const fastify = Fastify({
    bodyLimit: 1048576 * 2,
    logger: { prettyPrint: true }
  });

  // plugins
  await require('./plugins/mongo-db-connector')(fastify);

  await fastify.register(require('fastify-express'));
  await fastify.register(require('./plugins/knex-db-connector'), {});
  await fastify.register(require('./routes/api'), { prefix: 'api' });

  // hooks
  fastify.addHook('onClose', (instance, done) => {
    const { knex } = instance;
    knex.destroy(() => instance.log.info('knex pool destroyed.'));
  });

  // middlewares
  fastify.use(cors());

  return fastify;
};

// implement inversion of control to make the code testable
module.exports = {
  build
};
Enter fullscreen mode Exit fullscreen mode

./src/start.js:
This is the file that I use to start the server.

 const { build } = require('./app');

const { APP_PORT } = require('./environment');

build()
  .then(app => {
    // run the server!
    app.listen(APP_PORT, (err, address) => {
      if (err) {
        app.log.error(err);
        process.exit(1);
      }

      app.log.info(`server listening on ${address}`);

      process.on('SIGINT', () => app.close());
      process.on('SIGTERM', () => app.close());
    });
  });

Enter fullscreen mode Exit fullscreen mode

This is how the project looks like on the console:
Alt Text

This is what the swagger ui documentation looks like:
Alt Text

let's finish

That’s all, I hope this helps somebody to learn something new if you have some questions or feedbacks please comment, thank you for reading.
I would like to make more post publications to try to help others.

Top comments (3)

Collapse
 
treeek profile image
John Schmitz

Great Post!
It would help though, if your code snippets had some form of syntax highlighting !

Collapse
 
cristiandi profile image
Cristian David Ippolito Carrillo

Thank you! I will follow your recommendation.

Collapse
 
cyb3rs4pi3n profile image
S M Debbarma

very thorough article! One thing to be rectified though. Multi server swagger doc is currently not supported in 'fastify-swagger'. As you would see that the swagger UI in the example does not show the drop-down for the different environment[local/development/staging/production] as specified in index.js of /docs

track issue here: github.com/fastify/fastify-swagger...