loading...

Demo API using fastify

cristiandi profile image Cristian David Ippolito Carrillo Updated on ・10 min read

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

./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
};

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);
  • ./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;

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;

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;

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
};

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;
  • ./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
};
  • ./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' }
    ]
  }
};

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
};

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
};

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;

./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
};

./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());
    });
  });

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.

Posted on by:

cristiandi profile

Cristian David Ippolito Carrillo

@cristiandi

I'm passionate code developer interested for learning new good things. Code is poetry <3

Discussion

pic
Editor guide
 

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

 

Thank you! I will follow your recommendation.

 

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...