Kelvin Omereshone Kelvin is an independent software maker currently building Sailscasts, a platform to learn server-side JavaScript. He is also a technical writer and works as a Node.js consultant, helping clients build and maintain their Node.js applications.

Building a Node.js web API with Sails.js

23 min read 6506

Building a Node.js Web API with Sails.js

Being able to write JavaScript on the server as well as the client can prove to be very good leverage for both small and large teams. However, building a production-ready Node.js Web API to power your already beautifully created, frontend, web, or mobile application is no easy task for the average developer. Some of the overheads you will encounter are:

  • A plethora of frameworks to chose from (Express, Hapi, Koa, etc.)
  • Having to research and aggregate the packages we needed for our project without any clear guidelines
  • Having to decide best practices in structuring our project’s codebase
  • Which ORM should we use or should we write raw queries? 🤔

In this article, we will learn how to build a production-ready Node.js web API using the Sails.js framework – an MVC framework for Node.js. We will be building the user management endpoints for a web API service. We will build endpoints to implement the following features:

  • User registration
  • User login
  • Email confirmation
  • Forget/reset password

We will use Postman to test our endpoints. Finally, we will deploy on Heroku. Let’s get started by first introducing Sails.js.

Prerequisites

This tutorial assumes the reader is fairly familiar with Node.js and server-side development.

What is Sails.js?

Sails.js or Sails is a real-time MVC framework for building production-ready enterprise Node.js applications. It was built in the year 2015 by Mike McNeil and the Sails Company with inspirations from the Ruby on Rails MVC framework. Sails comes out of the box with websocket support making it suitable for building real-time chat apps, games, and more. Sails also ships with:

  • An ORM called Waterline – an adapter-based ORM for Node.js – to make the data layer of your application a breeze
  • A CLI tool called sails that helps you scaffold a new sails application, generate controller actions, start up the development server, database migrations, and lots more

Sails is actively being used in the wild by companies like Postman, Paystack, and devmountain to build out their Web APIs to power their various clients.

Note: Sails was built on top of the popular Express Node.js framework so it should feel familiar if you have built Node.js applications with Express.

Getting started

To get started building our web API in Sails, we will need to install the Sails CLI tool. We will do so by running the below command in the terminal.

I am assuming you have Node.js(version 13.12.0) installed

npm install -g sails

The above command will install the Sails CLI tool globally on your machine. To verify that Sails is installed run:

sails -v

The above command should return a version number if everything went successfully.

Creating a new Sails application

To create a new Sails application, run the sails new command passing in the name of your application. This command will both generate a new Sails app and run npm install to install all dependencies for you.

sails new <appname>

The new command also takes in optional flags and we will be using the --no-frontend flag to tell Sails to not generate “assets”, “views” or “task” folders. Which is what we want since we are building a web API. Let’s run the command like so:

sails new logrocket-sails-api --no-frontend

Pro tip: You can run the new command with the —fast flag to just generate the sails app and skip installation of dependencies

Now you can cd into the logrocket-sails-api directory and open the app in your editor. We will be using VS Code so we will just run code . to open the current directory in VS Code.

Project codebase structure

Sails code name structure

The above screenshot depicts the codebase structure the sails new command generated for us. Let’s go through what some of the files and folders are:

  • api/ – This directory is where you will spend most of your development time. It contains the controllers/, helpers/, models/, and policies/ subdirectories by default
  • controllers/ – This directory will house all controllers in your Sails app
  • helpers/ – In Sails, helpers are a reusable piece of code that follows the node-machine spec. The helpers/ directory will contain all helpers you define in your application
  • models/ – This directory will contain your application Waterline models. Waterline models are typically just a .js file containing database schema for your database table
  • policies/ – Policies are Sails mechanism for authorization and access control. You define them in this folder
  • config/ – this directory contains all configuration files for your sails app. One particular file worthy of note is the routes.js config file which is used to declare routes your Sails applications will be handling requests through
  • config/datastore.js – In this config file you define your database adapter which Waterline will use(more on this later on)
  • config/policies.js – In here you define policies and the actions or controllers the policies will be guarding

You can read more about the other files and folders in the Sails docs.

Our first endpoint

Right now if you start the Sails development server by running sails lift, and you visit localhost:1337, you will get a 404 because we haven’t defined any routes for our application just yet.

sails lift

Not found

Pro tip: You can change the port sails that will be listening to by running sails lift -- port <port-no>

Let’s create our first endpoint. Typically you will need to touch routes.js and add the endpoint. The routes.js file takes a dictionary/object where the key is the route endpoint like say / and the value is the controller action that will handle that route request. The route key must contain an HTTP verb supported by Sails(GET|POST|PATCH|DELETE) and then a space followed by the route.

Let’s add the route for the / index of our application. Open config/routes.js and add the below code snippet:

"GET /": "home/index"

What the above route means is that for a GET request, the index action(method) in the home controller will handle that request.

This a good time to mention that in Sails you have the flexibility of having your controller actions(methods) in a separate file as opposed to defining them all in a single controller file. This approach makes the actions more readable so each action file is focused. We will be taking this approach of having the directory to represent the controller and the .js files inside as the actions.

Your routes.js file should look like this:

routes.js

We are halfway done in creating our first endpoint. The next step is to create the index action. We will use the Sails CLI generate command to create the action in controllers/home directory. Run the below command:

sails generate action home/index

sailssuccessfullygenerated

We are not passing in the file extension as Sails is smart enough to include that. So you typically pass in the argument as you did for the routes property in routes.js

When we run the above command a new folder home will be added in controllers/ directory and the index.js action should be inside it. Below is the default content of the index.js action:

inputs is undefined but never used message

The format of actions and helpers in Sails follows the Node Machine spec. An article to help you understand this spec can be found here.

What we will want to do is to return a JSON response when a request is made to / endpoint. Modify home/index.js to look like this:

module.exports = {
  friendlyName: 'Index',
  description: 'Index home.',
  inputs: {
  },
  exits: {
  },
  fn: async function (_, exits) {
    // All done.
    exits.success({message: 'LogRocket Sails API'});
  }
};

By default, Sails actions uses exits.success to return a success response for every request. So when a user visits / or our Web API, a JSON payload with the property message will be sent back as the response.



Now we have gotten our feet wet, let’s go onward to implement our user management and authentication feature. We will begin with the registration endpoint.

New user registration endpoint

To get started let’s state what we want to do with this endpoint:

  • Setup database connection
  • Store a new user record in the database
  • Send an email confirmation to the new user
  • Send back a response to the user after a successful registration
  • Optionally send back errors if they occurred during the process of registration

Set up database connection

We will be using PostgreSQL. You can follow this tutorial to install PostgreSQL for your machine.

Sails has an official PostgreSQL adapter for Waterline which we will install in the same way we install any npm package by running:

npm install sails-postgresql --save

After installing the adapter, you need to supply the adapter name and your local database connection URL in config/datastore.js.

You should have created your Postgres database before now. See here for help.

Modify config/datastore.js by uncommenting the line:

// adapter: 'sails-mysql',
// url: 'mysql://user:[email protected]:port/database',

And replacing with:

adapter: 'sails-postgresql',
url: 'postgres://logrocket_sails_api:[email protected]:5432/logrocket_sails_api', // Replace with your own connection URL

The connection URL for PostgreSQL usually takes the form:

"postgres://{user}:{password}@{hostname}:{port}/{database-name}"

If the Sails development server is still running, kill it, and re-lift Sails by running sails lift.

Pro tip: If you are on VS code you can install Sailboat– the Sails tooling for VS code and you can start the Sails development server from the Command Palette.

If Sails runs without any error then your database has been connected successfully.

Let’s move on to creating our user schema in the next step.

Schema declaration and user model

Since PostgreSQL is a schema-based database we won’t be able to create tables or new records on that those tables without an explicitly defined schema. Waterline takes care of defining that schema and provides us the API of just setting JavaScript objects to define them.

For our users’ table, we want the following columns, full_name, email, email_status, email_proof_token , email_proof_token_expires_at, password, password_reset_token, and password_reset_token_expires_at.


More great articles from LogRocket:


Let’s create the user model by running:

sails generate model user

Sails will proceed to create a model file named User.js in models/User.js. Open this file and add the following code inside the attributes property:

fullName: {
      type: 'string',
      required: true,
      columnName: 'full_name'
    },
    email: {
      type: 'string',
      required: true,
      unique: true,
    },
    emailStatus: {
      type: 'string',
      isIn: ['unconfirmed', 'confirmed'],
      defaultsTo: 'unconfirmed',
      columnName: 'email_status'
    },
    emailProofToken: {
      type: 'string',
      description: 'This will be used in the account verification email',
      columnName: 'email_proof_token'
    },
    emailProofTokenExpiresAt: {
      type: 'number',
      description: 'time in milliseconds representing when the emailProofToken will expire',
      columnName: 'email_proof_token_expires_at'
    },
    password: {
      type: 'string',
      required: true
    }
passwordResetToken: {
      type: 'string',
      description:
        'A unique token used to verify the user\'s identity when recovering a password.',
      columnName: 'password_reset_token',
    },
    passwordResetTokenExpiresAt: {
      type: 'number',
      description:
        'A timestamp representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).',
      example: 1508944074211,
      columnName: 'password_reset_token_expires_at',
    },

Using Waterline attributes definition specification, we defined the model attributes which represent the columns in the user table.

Sails will use the attribute keys as the column names if you don’t specify a value for them with the columnName property

Check the documentation on Waterline attributes for more insight on setting model attributes in Sails.

Before we wrap this up we need to specify a table name for our model. This will tell Waterline what name the table would be. So we will modify User.js and add this property:

tableName: "users"

User.js should now look like this:

/**
 * User.js
 *
 * @description :: A model definition represents a database table/collection.
 * @docs        :: https://sailsjs.com/docs/concepts/models-and-orm/models
 */
module.exports = {
  tableName: "users",
  attributes: {
    fullName: {
      type: 'string',
      required: true,
      columnName: 'full_name'
    },
    email: {
      type: 'string',
      required: true,
      unique: true,
    },
    emailStatus: {
      type: 'string',
      isIn: ['unconfirmed', 'confirmed'],
      defaultsTo: 'unconfirmed',
      columnName: 'email_status'
    },
    emailProofToken: {
      type: 'string',
      description: 'This will be used in the account verification email',
      columnName: 'email_proof_token'
    },
    emailProofTokenExpiresAt: {
      type: 'number',
      description: 'time in milliseconds representing when the emailProofToken will expire',
      columnName: 'email_proof_token_expires_at'
    },
    password: {
      type: 'string',
      required: true
    }
  },
};

Sails allows you to customize the fields to be returned when you are returning a record as a response. We would use this functionality to remove the password field when returning the user record. Let’s add that. After the attributes:{} add the following:

customToJSON: function () {
    return _.omit(this, ["password"]);
  },

We are telling Sails that when converting instances of the User.js model to JSON it should always omit the password property.

One last thing we will do in User.js is to encrypt the password before storing it. You can do this in the controller action but we are going to use the Waterline lifecycle hook. Waterline provides a beforeCreate lifecycle hook on its models allowing you to perform some tasks before any record is created.

To encrypt our password we will install a Sails hook called sails-hooks-organics(hooks are sails mechanism for extending Sails abilities). These hooks provide helpers for hashing passwords, comparing password, and creating random strings(which we will need for our tokens). Let’s install it:

npm install sails-hook-organics --save

Once it is installed, add this code after the customToJSON property:

// LIFE CYCLE
beforeCreate: async function (values, proceed) {
  // Hash password
  const hashedPassword = await sails.helpers.passwords.hashPassword(
    values.password
  );
  values.password = hashedPassword;
  return proceed();
},

Let’s migrate our database! We can do this by restarting Sails by running sails lift. However, Sails will prompt you to enter a migration strategy which should be one of the following:

  • Alter(wipe/drop and try to re-insert ALL our data (recommended)),
  • Drop(wipe/drop ALL our data every time we lift Sails), safe(don’t auto-migrate our data, we will do it ourselves)

When you see the prompt enter “alter” which is recommended for development.

Pro tip: To avoid this prompt every time you lift Sails you should explicitly set the migration strategy in config/models.js by uncommenting this line:

// migrate: 'alter',

Also, uncomment in config/models.js:

// schema: true,

Finally, since we are using snake_case for our column names we will modify the auto-created attributes, createdAt and updatedAt in config/models.js by adding columnName to both of them. Here is how they should look:

createdAt: { type: 'number', autoCreatedAt: true, columnName: 'created_at'},
updatedAt: { type: 'number', autoUpdatedAt: true, columnName: 'updated_at'},

Finally, run sails lift for your changes to take effect in the database.

Setting up the register endpoint and controller

Now we have our user model all set up and ready for queries, let’s create the route and controller(action) for creating new users. We will begin by declaring the route in routes.js:

'POST /user/register': 'user/register'

Run the below command to create the register action:

sails generate action user/register

Open the user/register.js action file. In Sails, if your action is expecting any data i.e a POST request, you define that data in the inputs field of the action. The syntax of the definition is similar to the attributes definition we did earlier on the User.js model. Add the following code to inputs:{} so it looks like this:

inputs: {
    fullName: {
      type: 'string',
      required: true,
    },
    email: {
      type: 'string',
      required: true,
      unique: true,
      isEmail: true,
    },
    password: {
      type: 'string',
      required: true,
      minLength: 6,
    },
  },

You can see Sails allows you to validate each input in line by setting some validation properties. Next, we will set up some exits which are possible outcomes for the endpoint. Recall the success exit means the request was successful and our web API will respond with a 200. However, you can customize the status code since this will be creating a new record we want to return a 201. Also, we want to set up a response for when a user already exists with the email since the email is a unique field. Finally, we will have a generic error exit to serve as a catch-all for any other unforeseen errors. Add the following code to exits:{} so it looks like this:

success: {
     statusCode: 201,
     description: 'New muna user created',
   },
   emailAlreadyInUse: {
     statusCode: 400,
     description: 'Email address already in use',
   },
   error: {
     description: 'Something went wrong',
   },

Before you can use the exits in the fn async function always pass in exit as an argument or it won’t work.

Let’s move on to the fn async function. We will start off by declaring a try-catch block. In that block start off with making sure the email is all lowercase:

const newEmailAddress = inputs.email.toLowerCase();

Then we will create a token for this user which will be used for the email verification:

const token = await sails.helpers.strings.random('url-friendly');

Next using the model method create(). On the user model we will create a new record but before we do that we have to add the expiration time of our token in config/custom.js – you use this file to define the values you want to be able to access in your application during development. So go to config/custom.js and add this:

emailProofTokenTTL: 24 * 60 * 60 * 1000, // 24 hours

Then back in user/register.js you can now create the new user record:

let newUser = await User.create({
        fullName: inputs.fullName,
        email: newEmailAddress,
        password: inputs.password,
        emailProofToken: token,
        emailProofTokenExpiresAt:
          Date.now() + sails.config.custom.emailProofTokenTTL,
      }).fetch();

Next, we will need to construct the confirm link which will be sent to the user:

const confirmLink = `${sails.config.custom.baseUrl}/user/confirm?token=${token}`;

Seeing we haven’t defined baseUrl in config/custom.js our code will break:

baseUrl: 'http://localhost:1337'

Note: the confirm endpoint doesn’t exist yet

The next thing to do is to set up and send our email:

const email = {
        to: newUser.email,
        subject: 'Confirm Your account',
        template: 'confirm',
        context: {
          name: newUser.fullName,
          confirmLink: confirmLink,
        },
      };
await sails.helpers.sendMail(email);

Finally, we will send a response to the user to show we’re all done creating a new user:

return exits.success({
      message: `An account has been created for ${newUser.email} successfully. Check your email to verify`,
    });

Let’s look at error-handling. In the catch block add the below code to first check if the error was caused as a result of trying to register with an existing email:

if (error.code === 'E_UNIQUE') {
        return exits.emailAlreadyInUse({
          message: 'Oops :) an error occurred',
          error: 'This email address already exits',
        });
}

Finally, we will respond to every other error with the exits.error:

return exits.error({
      message: 'Oops :) an error occurred',
      error: error.message,
});

Sending emails

We can’t test the above registration just yet because we haven’t set up sending emails for our app. Let’s do that.

We will be using nodemailer to power our email sending flow. We will also use SendGrid as the nodemail transport. To create our emails we will use the handlebar plugin for nodemailer to compile .hbs template files to emails. Let’s start off by installing these packages:

npm install nodemailer nodemailer-express-handlebars nodemailer-sendgrid --save

After installing we will generate a SendGrid API key which is essential to send these emails. We will add this API key in config/local.js.

local.js will only be available in development so you will need to create local.js if it does not exist and provide your SendGrid API key in order to test this app locally

send-mail helper

Notice we are calling:

await sails.helpers.sendMail(email);

We haven’t created this helper yet. To do this we will run:

sails generate helper send-mail

This will create send-mail.js in helpers/ directory. Replace the content with this:

const nodemailer = require("nodemailer");
var nodemailerSendgrid = require("nodemailer-sendgrid");
const hbs = require("nodemailer-express-handlebars");
module.exports = {
  friendlyName: "Send mail",
  description: "",
  inputs: {
    options: {
      type: "ref",
      required: true,
    },
  },
  exits: {
    success: {
      description: "All done.",
    },
  },
  fn: async function (inputs) {
    const transporter = nodemailer.createTransport(
      nodemailerSendgrid({
        apiKey: sails.config.sendGridAPIkey || process.env.SENDGRID_API_KEY,
      })
    );
    transporter.use(
      "compile",
      hbs({
        viewEngine: {
          extName: ".hbs",
          partialsDir: "./views",
          layoutsDir: "./views",
          defaultLayout: "",
        },
        viewPath: "./views/",
        extName: ".hbs",
      })
    );
    try {
      let emailOptions = {
        from: "LogrocketSailsAPI <[email protected]>",
        ...inputs.options,
      };
      await transporter.sendMail(emailOptions);
    } catch (error) {
      sails.log(error);
    }
  },
};

This helper is taking option as it’s only arguments which will be an object containing information relevant to sending the email like the subject, template file, etc. In the transporter.use method we are telling nodemailer to look for .hbs files in the views directory to make sure this directory is created.

Finally, we will create the confirm email template .hbs file. Then add the following template:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
        body {
            padding: 10px 20px;
            font-family: Arial, Helvetica, sans-serif;
        }
        .cta {
            text-decoration: none;
            background-color: rgb(76, 119, 175);
            color: #fff !important;
            font-weight: bold;
            padding: 10px 20px;
            border-radius: 5px;
            margin: 0 auto;
        }
        footer {
            margin-top: 2rem;
        }
        p {
            margin: 30px 0;
        }
    </style>
</head>
<body>
    <section class="hero">
        <h1 class="title">Welcome, {{ name }}</h1>
    </section>
    <main>
        <p>Please confirm your account by clicking the button below:</p>
        <a class="cta" href="{{confirmLink}}" target="_blank">Confirm email</a>
        <p>Once confirmed, you'll be able to log in.</p>
    </main>
    <footer>
        <p>🖤 Love,</p>
    </footer>
</body>
</html>

Notice we are using handlebars’ interpolation to make available the name and confirmLink to the user which we passed in as part of the email option when we called sendMail.

All done! So now we have completed the registration process. Run sails lift and open Postman to test this endpoint. Enter an active email and you should be registered and an email should be sent to you as well.

sails lift with postman with a success message

 

Email confirmation

Open routes.js and add the following entry to the routes dictionary:

GET /user/confirm': 'user/confirm

Then run sails generate action user/confirm to scaffold the confirm action.

We want this route to take in a single query param called token which is the confirmation token for the email. So we will add a single input to inputs:{}. Let’s add that:

token: {
    type: 'string',
    description: "The confirmation token from the email.",
    example: "4-32fad81jdaf$329",
  },

We will also add two exits:

success: {
      description: "Email address confirmed and requesting user logged in.",
    },
  invalidOrExpiredToken: {
    statusCode: 400,
    description:
      "The provided token is expired, invalid, or already used up.",
  },

In the fn function we will start off by checking if the request does not contain a token parameter:

if (!inputs.token) {
      return exits.invalidOrExpiredToken({
        error: "The provided token is expired, invalid, or already used up.",
      });
    }

After that we will get the user who was given the token from the database:

var user = await User.findOne({ emailProofToken: inputs.token });

We will then check if there is no such user or if the token is expired already:

if (!user || user.emailProofTokenExpiresAt <= Date.now()) {
     return exits.invalidOrExpiredToken({
       error: "The provided token is expired, invalid, or already used up.",
     });
   }

Finally, we will check if the user has an emailStatus of unconfirmed and proceed to updating that to confirmed and send back a response to the caller:

if (user.emailStatus === "unconfirmed") {
      await User.updateOne({ id: user.id }).set({
      emailStatus: "confirmed",
      emailProofToken: "",
      emailProofTokenExpiresAt: 0,
  });
  return exits.success({
    message: "Your account has been confirmed",
  });
}

To test this out, run sails lift and then create a new user in Postman with a valid email address you have access to. Then check for the confirmation email and click on the confirm email button.

Email Confirmation Message

This should show the message Your account has been confirmed if everything was successful.

words "your account has been confirmed" on blank screen

Our users can now create accounts and confirm their password but they can’t log in quite yet. Let’s fix that!

The login feature

To implement the login feature, we will declare the below route in routes.js:

POST /user/login': 'user/login

We will then create the login action by running sails generate action user/login in the terminal.

For a user to be able to login, we want the following to be true:

  1. The email address must have been registered
  2. The email address must have been confirmed

To enforce this, we will write a policy called can-login.js in policies/ directory. Below is the content of this policy:

module.exports = async function (req, res, proceed) {
  const { email } = req.allParams();
  try {
    const user = await User.findOne({ email: email });
    if (!user) {
      res.status(404).json({
        error: `${email} does not belong to a user`,
      });
    } else if (user.emailStatus === 'unconfirmed') {
      res.status(401).json({
        error: 'This account has not been confirmed. Click on the link in the email sent to you to confirm.',
      });
    } else {
      return proceed();
    }
  } catch (error) {
    res.status(401).json({ error: error.message });
  }
};

Here, we are using the findOne method provided by Waterline on the user model to get the email provided in the login request. If the record wasn’t found we return a 404 with a message to the caller. Next, we check if the emailStatus is unconfirmed, if it is then the user hasn’t confirmed his/her account yet so we return a 401(not authorized). Finally, we allow the request to go through with return proceed() callback if none of the previous conditions were true.

Setting the policy

Writing the policy is just one part of the process to add a guard to an action. The other part is to map the policy to the action in it will be action on in config/policies.js file. To do this, simply add the following code snippet as an entry to the object in the file:

"user/login": 'can-login'

Now flesh out the user/login.js action. First, we will add the inputs to the inputs: {} object:

email: {
    type: "string",
    required: true,
  },
password: {
  type: "string",
  required: true,
},

We will also add the following exits:

success: {
      description: "Login successful",
    },
  notAUser: {
    statusCode: 404,
    description: "User not found",
  },
  passwordMismatch: {
    statusCode: 401,
    description: "Password do not match",
  },
  operationalError: {
    statusCode: 400,
    description: 'The request was formed properly'
}

Then in the fn function we will start out with a try/catch block. In the try block we will attempt to find the user with the provided email:

const user = await User.findOne({ email: inputs.email });

We will check if a user was not found and then exit with the notAUser exit:

if (!user) {
   return exits.notAUser({
     error: `An account belonging to ${inputs.email} was not found`,
   });
}

Going on, we will check the password using the checkPassword method provided by the sails-hook-organics hooks we installed in part one:

await sails.helpers.passwords
    .checkPassword(inputs.password, user.password)
    .intercept('incorrect', (error) => {
      exits.passwordMismatch({ error: error.message });
});

Then we will generate a new JWT token(we haven’t implemented this just yet):

const token = await sails.helpers.generateNewJwtToken(user.email);

We will then set a me object with the user record on the req stream/object:

this.req.me = user;

Lastly, we will return the success exit providing a message, the user properties as data, and the JWT token:

return exits.success({
      message: `${user.email} has been logged in`,
      data: user,
      token,
});

In the catch block we will first log any errors using Sails built-in logger:

sails.log.error(error);

Then we will check if the error was an operation error. If it is we will return a JSON with the raw message to the user:

if (error.isOperational) {
  return exits.operationalError({
    message: `Error logging in user ${inputs.email}`,
    error: error.raw,
  });
}

For every other error, we don’t know about we will return a 500 using the error exit:

return exits.error({
       message: `Error logging in user ${inputs.email}`,
       error: error.message,
 });

We can’t quite test the login feature because we have not created generateNewJwtToken helper.

JWT for authentication

We will install the jsonwebtoken package as a dependency by running:

npm install jsonwebtoken --save

We will then create generateNewJwtToken helper:

sails generate helper generate-new-jwt-token

Open up the file and require the jsonwebtoken package at the top:

const jwt = require("jsonwebtoken");

This helper will be taking in a single argument which is the user’s email(we are using the email as a unique identifier to create the JWT tokens). So in the inputs field we add:

subject: {
    type: "string",
    required: true
}

Now let’s look into the body of the helper. We will start off by creating the payload object required by JWT to issue a token:

const payload = {
     sub: inputs.subject, // subject
     iss: "LogRocket Sails API" // issuer
};

We will then, in config/local.js, add an entry called jwtSecret which should be a string.

We are not storing the secret directly in our code as that’s bad for security.

Since config/local.js won’t be available when we are in production, we will be adding an environment variable called JWT_SECRET in our server environment. So add the below line to the helper:

const secret = sails.config.jwtSecret || process.env.JWT_SECRET;

The above line simply will try to get the secret from config/local.js when in development and fall back to get the value from the environment variable JWT_SECRET when not in development (i.e production).

Finally, we will create the token and set it to expire in 24 hours:

const token = jwt.sign(payload, secret, { expiresIn: "1d" });

Lastly, we return the token to the caller:

return token;

Testing our web API

Let’s test the login feature in Postman using the user account we already created. Provide the endpoint with the email and password and you should get back a response from the API similar to the one in the screenshot below:

Postman message with data

Forgot password

Here is a scenario, a user comes back to one of the clients consuming our API and can’t recall their account password. Let’s make sure we help to get back in. To implement the forgotten password feature we will start off with the route declaration in routes.js:

'POST /user/forgot-password': 'user/forgot-password'

Let’s also create the action file:

sails generate action user/forgot-password

We want to protect this endpoint to only those who can log in. We already implemented a policy called can-login so we can reuse it. In config/policies.js add the following entry:

'user/forgot-password': 'can-login'

The way we want the forgot-password to work is that a POST request with the email of the user claiming to have forgotten their password is sent to the endpoint and we will send a recovery link to the user’s inbox. To begin let’s declare the email input:

email: {
        description:
          "The email address of the user who wants to recover their password.",
        example: "[email protected]",
        type: "string",
        required: true,
    },

We add the success exit:

success: {
     description:
       "Email matched a user and a recovery email might have been sent",
 },

For fn we begin with trying to get the user matching that email address and we don’t proceed if there is not a match:

var user = await User.findOne({ email: inputs.email });
   if (!user) {
     return;
   }

We then create a recovery token that will be sent to the user:

const token = await sails.helpers.strings.random("url-friendly");

We then store the token in the database along with an expiration time on it:

await User.update({ id: user.id }).set({
      passwordResetToken: token,
      passwordResetTokenExpiresAt:
        Date.now() + sails.config.custom.passwordResetTokenTTL,
});

Presently there is passwordResetTokenTTL property in config/custom.js let’s add that and the value:

passwordResetTokenTTL: 24 * 60 * 60 * 1000, // 24 hours

Back in user/forgot-password.js action we will proceed to construct the reset link for now we will just make it point directly to the endpoint responsible for resetting the password.

This link is subjective to how you want the reset endpoint to be consumed by clients. Since we are not focused on the frontend we will simply copy the reset token and use it in Postman

Here is the snippet:

const recoveryLink = `${sails.config.custom.baseUrl}/user/reset-password?token=${token}`;

We will then send out the email using the email template called forgot-password.hbs which we haven’t created yet. So, create forgot-password.hbs in views/ directory. Add the following template to it:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
        body {
            padding: 10px 20px;
            font-family: Arial, Helvetica, sans-serif;
        }
        .cta {
            text-decoration: none;
            background-color: rgb(76, 119, 175);
            color: #fff !important;
            font-weight: bold;
            padding: 10px 20px !important;
            border-radius: 5px;
            margin: 0 auto;
        }
        footer {
            margin-top: 2rem;
        }
        p {
            margin: 30px 0;
        }
    </style>
</head>
<body>
    <section class="hero">
        <h1 class="title">Forgot your password {{ name }}?</h1>
    </section>
    <main>
        <p>
            Someone requested a password reset for your account. If this was not
            you, please disregard this email. Otherwise, simply click the button
            below:
        </p>
        <a class="cta" href="{{recoverLink}}" target="_blank">Reset password</a>
    </main>
    <footer>
        <p>🖤 Love,</p>
    </footer>
</body>
</html>

Back in the forgot-password action you will construct the email option and send the email with it:

const email = {
      to: user.emailAddress,
      subject: "Reset Password",
      template: "forgot-password",
      context: {
        name: user.fullName,
        recoverLink: recoveryLink,
      },
    };
    try {
      await sails.helpers.sendMail(email);
    } catch (error) {
      sails.log(error);
    }

Finally, we will send back a response to the caller with the success exit:

return exits.success({
      message: `A reset password email has been sent to ${user.email}.`,
    });

Before we test out the forgot password feature let’s wrap up by implementing the reset password feature.

Reset password

Add the below entry to routes.js file:

"POST /user/reset-password": "user/reset-password",

Create the action by running:

sails generate action user/reset-password

Open up reset-password.js and add the following two inputs as we would expect the reset token and the new token to be sent in the request:

password: {
     description: "The new, unencrypted password.",
     example: "myfancypassword",
     required: true,
   },
   token: {
     description:
       "The password token that was in the forgot-password endpoint",
     example: "gwa8gs8hgw9h2g9hg29",
     required: true,
   },

Also declare the following exits:

success: {
     description:
       "Password successfully updated, and requesting user agent automatically logged in",
   },
   invalidToken: {
     statusCode: 401,
     description:
       "The provided password token is invalid, expired, or has already been used.",
   },

In the fn we check to see if the request contains a token else we quickly bail via an invalidToken exit:

if (!inputs.token) {
     return exits.invalidToken({
       error: "Your reset token is either invalid or expired",
     });
}

If the token is valid we proceed to get the user record having that token:

var user = await User.findOne({ passwordResetToken: inputs.token });

We will also check if a user record was retrieved and if the token hasn’t expired yet:

if (!user || user.passwordResetTokenExpiresAt <= Date.now()) {
    return exits.invalidToken({
      error: "Your reset token is either invalid or expired",
    });
  }

We will then hash the plain password supplied by the caller:

const hashedPassword = await sails.helpers.passwords.hashPassword(
      inputs.password
    );

Then we update the user record with the hashedPassword and reset both the passwordResetToken and passwordResetTokenTTL fields:

await User.updateOne({ id: user.id }).set({
          password: hashedPassword,
          passwordResetToken: "",
          passwordResetTokenExpiresAt: 0,
    });

To authorize the user we will create a JWT token:

const token = await sails.helpers.generateNewJwtToken(user.email);

We finally add the user record to the req object and send back a 200 response via the success exit with a payload containing a message, user information, and the JWT token for authorization:

this.req.user = user;
return exits.success({
  message: `Password reset successful. ${user.email} has been logged in`,
  data: user,
  token,
});

Let’s test these two endpoints. Go back to Postman and hit the forgot password endpoint with the account email you created earlier. Go to your inbox then click on the link and copy the token. After that go back to Postman and hit the reset password endpoint with both the token and the new password. You should get the following responses from both endpoints:

forgot password endpoint

 

forgot password email

 

password reset successful

Try to login in with the new password and it should also work:

new password login

Now we successfully implemented all endpoints. You can take them all for a spin in Postman. To wrap up let’s make our web service available to the world.

Deployment

We will deploy our API on the Heroku PaaS. Before we deploy we will need to do some house cleaning. Let’s get started opening config/production.js which will contain all the production settings for our API.

Look for the line under http property, // trustProxy: true and uncomment it to allow Sails to be served from Heroku.

Next, we will override our development database with our production database by replacing the following two entries in the default property of the datastore object:

adapter: "sails-postgresql",
url: process.env.DATABASE_URL,

Heroku will set a DATABASE_URL env variable when we you add a PostgreSQL database to your app

Remember also to replace the baseUrl in config/production.js with your Heroku app URL so emails will work as well.

After that’s done, create a Heroku account and you can deploy your API as you will deploy any Node.js app on Heroku.

Make sure you add the Heroku PostgreSQL add-on since you are depending on a database. Also, set the environment variables, JWT_SECRET and SENDGRID_API_KEY.

To get your database schema onto Heroku, you could copy the database URL and run sails lift that will migrate the tables unto the Heroku provisioned database. Heroku database will need SSL to connect to its database so add ssl: true to the adapter properties like so:

// config/datastore.js
  adapter: "sails-postgresql",
  url:"heroku-database-url",
  ssl: true

Lastly, for security you need to pass an array of URLs that will be allowed to connect to your app via sockets – this is because Sails comes with built-in websockets support by default. Although we are not using sockets. Just find the line under sockets in config/production.js and uncomment the line below:

// onlyAllowOrigins: [
    //   'https://example.com',
    //   'https://staging.example.com',
    // ],

Replace the line with your Heroku app URL:

onlyAllowOrigins: ['https://logrocketsailsapi.herokuapp.com'], // change to yours!

After this, you can deploy your Node.js web API powered by Sails on Heroku!

It’s a wrap!

In this article, we have explored the power and ease of creating web APIs in Node.js using the Sails MVC framework. Here are a few more resources to help:

The source code for this article is on GitHub. Also here is a Postman collection for you to test the endpoints with.

200’s only Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Kelvin Omereshone Kelvin is an independent software maker currently building Sailscasts, a platform to learn server-side JavaScript. He is also a technical writer and works as a Node.js consultant, helping clients build and maintain their Node.js applications.

5 Replies to “Building a Node.js web API with Sails.js”

  1. Hey Kelvin, I am a bit confused with the sections for registering a user. You say “Let’s look at error-handling. In the catch block add the below code to first check if the error was caused as a result of trying to register with an existing password:”

    1. When you say “password”, at the end, you meant “Email”?
    2. Which one is the Catch Block? The one within the “exists: {” section?

    Thank you!

  2. Hey Dani, you are right I meant Email and also recall this line “Let’s move on to the fn async function. We will start off by declaring a try-catch block. In that block start off with making sure the email is all lowercase:” So we created a try/catch block for the entire fn implementation as it’s good practice to have as few try/catch blocks as possible to reduce the surface area of error handling in your application.

    I hope this helps, Thanks.

  3. Kelvin, thank you for getting back to me. I went over the tutorial and realized that indeed you were mentioning the try/catch block. I love this tutorial and how I am grasping on the concepts. I really appreciate it.

  4. Hi Kelvin,
    I couldn’t get a sendGrid API key, they refused to allow me to continue further, probably because my location is Nigeria, please what can I do? Because, I can’t test without the API key. Thanks

Leave a Reply