DEV Community

Ekekenta Odioyenfe .C
Ekekenta Odioyenfe .C

Posted on • Updated on

Building a Node.js Blog API Using Fastify and Fauna

I have the GitHub repository for this project here, which you can use to follow along.

If you don't have Node.js installed already, I recommend downloading the LTS installer from the official website. This will also install NPM which you need to manage your dependencies.

For our server we'll be using Fastify which is easy to use and offers a great developer experience.

Fastify is an open-source Node. js web framework that comes with minimal performance overhead and a flexible plugin architecture

We will be using Fauna as our database management system to store and retrieve our blog data.

Fauna is a data API for client-serverless applications. It offers a web-native GraphQL interface, with support for custom business logic and integrations.

In this article, we'll cover the following:

  • Creating a Fauna Database
  • Installing Dev Dependencies
  • Creating a Custom Error Class
  • Creating Users
  • The Document relationship
  • Creating, Updating, Retrieving, and Deleting A Blog
  • Authenticating Users

Getting Started

To get started, create a folder for your project and access it from your terminal. Open the folder in Visual Studio Code or any other IDE you prefer.

C:\Users\Programmer>mkdir fasify-blogapi && cd fasify-blogapi
Enter fullscreen mode Exit fullscreen mode

The above code open the current directory in Visual Studio Code. Note: This is only possible if the IDE was added to your system path during installation.

Then initialize NPM with this command:

npm init -y
Enter fullscreen mode Exit fullscreen mode

This should create a package.json file in your project folder which we can ignore for now.
Next, we're going to install our project dependencies:

npm install fastify faunadb --save
Enter fullscreen mode Exit fullscreen mode

This will install Fastify, Fauna and save them as dependencies in the package.json file.

Finally, we will create our entry point index.js in your project folder file:

const fastify = require('fastify')({ logger: true });

async function start () {
  try {
    await fastify.listen(5000);
    fastify.log.info(`server listening on ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err)
    process.exit(1);
  }
};

start();
Enter fullscreen mode Exit fullscreen mode

In our index file, we will require Fastify and create a listening port for our server.

Copy the above code to the index.js file we just created and test if everything is working as expected with this command:

node index.js
Enter fullscreen mode Exit fullscreen mode

You should see something similar to this on the console:

{"level":30,"time":1623727810214,"pid":6212,"hostname":"DESKTOP-TPC80JT","msg":"Server listening at http://127.0.0.1:5000"}
{"level":30,"time":1623727810253,"pid":6212,"hostname":"DESKTOP-TPC80JT","msg":"server listening on 5000"}
Enter fullscreen mode Exit fullscreen mode

Press CTRL + C if you wish to stop the server.

Fauna Creating a Fauna Database

Create a free Fauna account and log in into the dashboard. You're now ready to create a new database.

I will be creating a database called BLOG_API, and will click on SAVE.

We will have to generate a server key to enable access to our database from our code. Click on the security section of the dashboard and create a new key. In the settings give it the Server role and click SAVE.

After creating this key you'll see the key's secret. This is what you'll use to access Fauna from Node. Create an .env file in the project root directory, copy the secret key and store it there as Fauna will never show it to you again.

FAUNA_SERVER_SECRET=fnAD7ngvMYACDdHcIxfu2Fcb43-VFFC_McFja-XV
Enter fullscreen mode Exit fullscreen mode

The variables we define in our .env file will be available as environment variables in our code. For example, to access our server secret we will use:

process.env.FAUNA_SERVER_SECRET
Enter fullscreen mode Exit fullscreen mode

To prevent the .env file and node_modules folder Git pushing it to our repository, create a .gitignore file with this :

.env
node_modules
Enter fullscreen mode Exit fullscreen mode

First, we need a collection to store the documents for our blogs.
To create the Blogs collection, run this query in the shell:

CreateCollection({
  name: "Blogs"
})
Enter fullscreen mode Exit fullscreen mode

Next, we need an index for our blogs:

CreateIndex({
  name: "blog_by_username",
  source: Collection("Blogs"),
  terms: [{field: ["data", "author"]}],
})
Enter fullscreen mode Exit fullscreen mode

We also need a collection to store the documents users.
To create the Users collection, run this query in the shell:

CreateCollection({
  name: "Users"
})
Enter fullscreen mode Exit fullscreen mode

Next, we need an index that will ensure unique usernames:

CreateIndex({
  name: "Users_by_username",
  source: Collection("Users"),
  terms: [{field: ["data", "username"]}],
  unique: true
})
Enter fullscreen mode Exit fullscreen mode

We're good for now. Let's go back to our code.
Installing dev dependencies
Before continuing to work on our API, let's install Nodemon and dotenv in our development dependencies:

npm install dotenv --save-dev
Enter fullscreen mode Exit fullscreen mode

Dotenv will allow us to inject environment variables into our server from a .env text file. Sensitive data such as API keys should never be hardcoded into our code or pushed to a Git repository.

Now add this code to the index.js file.

const dovenv = require("dotenv").config()
Enter fullscreen mode Exit fullscreen mode

Lets us configure our start script to run the server using nodemon. Copy this code to your package.json file.

  "scripts": {
  "start": "node index.js",
  },
Enter fullscreen mode Exit fullscreen mode

Now let us run our server using nodemon:

 nodemon start
Enter fullscreen mode Exit fullscreen mode

Creating a Custom Error Class

Before we start working on our server routes, be prepared to receive errors from Fauna. For this, we will create a custom FaunaError class that can be easily integrated into Fastify's error handling flow.

Create the file errors/FaunaError.js and paste this:

class FaunaError extends Error {constructor (error) {
    super();

    const errors = error.requestResult.responseContent.errors;

    this.code = errors[0].code;
    this.message = errors[0].description;
    this.statusCode = 500;

    if (this.code === 'instance not unique'){
      this.statusCode = 409;
    }

    if (this.code === 'authentication failed') {
      this.statusCode = 401;
    }

    if (this.code === 'unauthorized') {
      this.statusCode = 401;
    }

    if (this.code === 'instance not found') {
      this.statusCode = 404;
    }

    if (this.code === 'permission denied') {
      this.statusCode = 403;
    }
  }
}

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

This class simply determines the HTTP status and description from the error returned by Fauna. You can customize this later with more errors or add your own error messages.
Fastify will read the StatusCode property and return the HTTP Code of the response.

Creating Users

Let's start our CRUD operations by creating our first Fastify route which will allow us to create users.
First we need to add this line in our index.js file before actually starting our server:

fastify.post('/api/users', require('./routes/create.user.js'));
Enter fullscreen mode Exit fullscreen mode

You can see the index.js file in the repository for the exact location of the code.
Now create the file routes/create.user.js in your project folder with this code:

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
// We do this so that our FQL code is cleaner
const {Create, Collection} = faunadb.query;

module.exports = {
  // Validation schema for the Fastify route
  schema: {
    body: {
      type: 'object',
      required: ['username', 'password'],
      properties: {
        username: {type: 'string'},
        password: {
          type: 'string',
          minLength: 6
        }
      }
    }
  },
  async handler (request, reply) {

    const {username, password} = request.body;

    const client = new faunadb.Client({
      secret: process.env.FAUNA_SERVER_SECRET
    });

    try {

      // Create a new user document with credentials
      const result = await client.query(
        Create(
          Collection('Users'),
          {
            data: {username},
            credentials: {password}
          }
        )
      );

      // Return the created document
      reply.send(result);

    } catch (error) {
      throw new FaunaError(error);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Since this is a public route, we're using our server secret to be able to execute queries.
Once our users have logged in, we will be using their own secret to execute queries. A user will only be able to perform the actions we have allowed in our authorization rules. We will discuss this more later.

Note that unlike other database clients, we are going to instantiate a new client on every request. We can safely do that because each query is simply an HTTP request, and the Fauna client is a very lightweight wrapper on top of the HTTP engine.

If for any reason Fauna returned an error, we'd only need to catch it and throw a new instance of our FaunaError class. Fastify will take care of the rest.

To test this route we can use any HTTP client. I will be using Thunder Client (which you can set up here if you use Visual Studio Code) but you can use whatever you're most comfortable with (eg: Postman, CURL, Insomnia, etc).

Let's make a POST request to:

http://localhost:3000/users
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the Content-Type header:

With the body :

{
  "username": "zion",
  "password": "icode247"
}
Enter fullscreen mode Exit fullscreen mode

If the request gets successful, you will see something like this.

We have successfully created our first blog user.R

Document Relationships

We need a way to associate a user to a blog, and that can be achieved by creating a relationship. Fauna FQL supports document relationships like one to one, one to many, many to One and many to many. You can read more about document relationships here.
For this article, we will be using one to many, Which implies that a user can create many blog posts.

Creating a Blog

Now we are going to create a blog post and create a reference to the user collection.
Create a new file in the routes directory routes/create.blog.js.

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
// We do this so that our FQL code is cleaner
const { Create, Collection, Ref } = faunadb.query;
module.exports = {
    // Validation schema for the Fastify route
    schema: {
        body: {
            type: 'object',
            required: ['author', 'title', 'body'],
            properties: {
                author: {
                    type: 'string'
                },
                title: {
                    type: 'string',
                },
                body :{
                    type :'string'
                }
            }
        }
    },
    async handler(request, reply) {
        const { author, title, body } = request.body;
        const client = new faunadb.Client({
            secret: process.env.FAUNA_SERVER_SECRET
        });
        try {
            // Create a new user document with credentials
            const result = await client.query(
                Create(
                    Collection('Blogs'),
                    {
                        data: {
                            author : Ref(Collection('Users'), author),
                            title,
                            body,
                            dateCreated: Date.now()
                        },
                    }
                )
            );
            // Return the created document
            reply.send(result);
        } catch (error) {
            throw new FaunaError(error);
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

We created a new blog and referenced a user as an author via the users ID.
Copy this code to the index.js entry point file.

fastify.post('/blogs', require('./routes/create.blog'));
Enter fullscreen mode Exit fullscreen mode

Now let us make a post request to this route using Thunder Client:

 http://localhost:3000/blogs
Enter fullscreen mode Exit fullscreen mode

With this data in the body:

{
  "author": "301518866529387009",
  "title": "How to start nodejs server",
  "body":"npm start",
}
Enter fullscreen mode Exit fullscreen mode

Should be something like this:

Authenticate Users

Now that we are able to create users and posts, we need to authenticate them to make sure a user is logged in before creating a post.

First let’s create an endpoint for our authentication, Copy this code to the index.js file.

fastify.post('/login', require('./routes/user.login.js'));
Enter fullscreen mode Exit fullscreen mode

Now create a login route /routes/user.login.js. Add this code to it:

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
const {Login, Match, Index} = faunadb.query;
module.exports = {
  schema: {
    body: {
      type: 'object',
      required: ['username', 'password'],
      properties: {
        username: {type: 'string'},
        password: {type: 'string'}
      }
    }
  },
  async handler (request, reply) {
    const {username, password} = request.body;
    const client = new faunadb.Client({
      secret: process.env.FAUNA_SERVER_SECRET
    });
    try {
      // Authenticate with Fauna
      const result = await client.query(
        Login(
          Match(Index('Users_by_username'), username),
          {password}
          )
        );
      // If the authentication was successful
      // return the secret to the client
      reply.send({
        secret: result.secret
      });
    } catch (error) {
      throw new FaunaError(error);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

If you noticed, we're using our Users_by_username index with the Login() and Match() function, to authenticate and login the user in.

Send a POST request to this route to log a user in.


fastify.post('/users/login', require('./routes/user.login.js'));
Enter fullscreen mode Exit fullscreen mode

Use this in the request body:

{
  "username":"zion",
  "password":"123456"
}
Enter fullscreen mode Exit fullscreen mode

If the authentication is successful, our API should return this response with the user's secret:

{
  "secret": "fnEELzp46FACBwQu26WxkAYHyJVFGDSV6-YCH9iTEZdhLSPJrd4"
}
Enter fullscreen mode Exit fullscreen mode

Save the secret somewhere for further requests to our API. We'll see how this works in the next route.

For simplicity's sake we're using a very basic form of authentication. You should decide very carefully which authentication strategy will work better for your use case and always use HTTPS when interacting with your serv

Retrieving a all blogs

Let's now create an endpoint to be able to read all our blogs. This is going to be a private route.
Private Hook
The best way to solve private routes in Fastify is using a hook. Hooks are custom bits of code that we can be trigger at certain points in the request/response flow. Check the Fastify documentation for more information on how to use them.
Our hook will check for the presence of a secret_token header on the routes we've marked as private. We also need to create a decorator to let Fastify know we will be modifying the request object.

Create a new directory in your project root directory /hooks/auth.hooks.js and add this code to it:

function initAuthHooks(fastify) {
    fastify.addHook('onRequest', async (request, reply) => {
        // If the route is not private we ignore this hook
        if (!reply.context.config.isPrivate) return;
        const faunaSecret = request.headers['secret-token'];
        // If there is no header
        if (!faunaSecret) {
            reply.status(401).send();
            return;
        }
        // Add the secret to the request object
        request.faunaSecret = faunaSecret;
    });
    fastify.decorateRequest('faunaSecret', '');
}
module.exports = initAuthHooks;
Enter fullscreen mode Exit fullscreen mode

Also, require the /hooks/auth.hooks.js file in our index.js entry file:

require("./hooks/auth.hook")(fastify)
Enter fullscreen mode Exit fullscreen mode

We don't really need to validate the secret. Fauna will return an error if we're using an invalid one.

The route
Add this to the index.js file:

http://localhost:3000/blogs
Enter fullscreen mode Exit fullscreen mode

Also, create the routes/get.all.blogs.js file with this:

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
const { Get, Collection, Lambda, Documents, Paginate, Map } = faunadb.query;
module.exports = {
  config: {
    isPrivate: true
  },
  async handler(request, reply) {
    const userId = request.params.userId;
    const client = new faunadb.Client({
      secret: request.faunaSecret
    });
    try {
      // Get the user document
      const result = await client.query(
        Map(
          Paginate(Documents(Collection('Blogs'))),
          Lambda(x => Get(x))
        )
      );
      // Return the document
      reply.send(result.data);
    } catch (error) {
      throw new FaunaError(error);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

We've added the isPrivate property in the config section of the route to mark this route as private for our hook.

Also note that we're now using the user provided secret to communicate with Fauna. Our user will now be subjected to the Fauna authorization rules instead of using the omnipotent server secret.
Users can’t access this endpoint if they are not logged in.

It's also possible to configure authorization rules exclusively using the shell and FQL queries, but for this tutorial we will be using the dashboard.
Go to the Security section of the dashboard, open the Roles tab, and click on New Custom Role.
Give it the name of User, add the Users collection, and click on the Read permission:

We also need to tell Fauna who belongs to this role.
Go to the Membership tab and select the Users collection as a member of this role. Click save.

Basically we've told Fauna that anybody logged in with a token based on a document from the Users collection can now read any document in the Users collection.
So if we text our blogs endpoint we should get a response like this.

[
  {
    "ref": {
      "@ref": {
        "id": "301522410208756231",
        "collection": {
          "@ref": {
            "id": "Blogs",
            "collection": {
              "@ref": {
                "id": "collections"
              }
            }
          }
        }
      }
    },
    "ts": 1623813028440000,
    "data": {
      "author": {
        "@ref": {
          "id": "301518866529387009",
          "collection": {
            "@ref": {
              "id": "Users",
              "collection": {
                "@ref": {
                  "id": "collections"
                }
              }
            }
          }
        }
      },
      "title": "How to start nodejs server",
      "body": "npm start",
      "dateCreated": 1623813027616
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

Before making the request, be sure to add the user's secret (the one you got after logging in) into a custom secret-token HTTP header:

Retrieving a single blog

Now let's retrieve a single blog from our collection.
Create a new file in the routes/get.blog.js.

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
const {Get, Ref, Collection} = faunadb.query;
module.exports = {
  config: {
    isPrivate: true
  },
  schema: {
    params: {
      type: 'object',
      required: ['blogId'],
      properties: {
        blogId: {
          type: 'string',
          pattern: "[0-9]+"
        }
      }
    }
  },
  async handler (request, reply) {
    const userId = request.params.userId;
    const client = new faunadb.Client({
      secret: request.faunaSecret
    });
    try {
        // Get the user document
        const result = await client.query(
            Get(
                Ref(
                    Collection('Blogs'),
                    blogId
                )
            )
        );
        // Return the document
        reply.send(result);
    } catch (error) {
        throw new FaunaError(error);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Copy this code to the index.js file:

fastify.get('/blogs/:blogId', require('./routes/get.blog.js'));
Enter fullscreen mode Exit fullscreen mode

Now go to your Blogs collection in Fauna, copy the id of the post we created and substitute the blogId.

http://localhost:3000/blogs/301522410208756231
Enter fullscreen mode Exit fullscreen mode

You will get a similar response to this:

{
  "ref": {
    "@ref": {
      "id": "301522410208756231",
      "collection": {
        "@ref": {
          "id": "Blogs",
          "collection": {
            "@ref": {
              "id": "collections"
            }
          }
        }
      }
    }
  },
  "ts": 1623813028440000,
  "data": {
    "author": {
      "@ref": {
        "id": "301518866529387009",
        "collection": {
          "@ref": {
            "id": "Users",
            "collection": {
              "@ref": {
                "id": "collections"
              }
            }
          }
        }
      }
    },
    "title": "How to start nodejs server",
    "body": "npm start",
    "dateCreated": 1623813027616
  }
}
Enter fullscreen mode Exit fullscreen mode

Updating a blog

We are now going to update our blog.
Create a new file routes/update.blog.js and write the following code.

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
const { Update, Ref, Collection } = faunadb.query;
module.exports = {
  config: {
    isPrivate: true
  },
  schema: {
    params: {
      type: 'object',
      required: ['userId'],
      properties: {
        userId: {
          type: 'string',
          pattern: "[0-9]+"
        }
      }
    }
  },
  async handler(request, reply) {
    const { body } = request.body
    const userId = request.params.userId;
    const client = new faunadb.Client({
      secret: request.faunaSecret
    });
    try {
      //Update the blog document
      const result = await client.query(
        Update(
          Ref(
            Collection('Blogs'),
            userId
          ), {
          data: {
            body
          }
        }
        )
      );
      // Return the document
      reply.send(result.data);
    } catch (error) {
      throw new FaunaError(error);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Go to your Fauna Blog collection and give a write permission a write and create privilege.
Copy this code to the index.js file:

fastify.get('/blogs/:blogId', require('./routes/update.blog.js'));
Enter fullscreen mode Exit fullscreen mode

Now send a patch request to update our blog post.

http://localhost:3000/blogs/301522410208756231
Enter fullscreen mode Exit fullscreen mode

With this request body:

{
    "body" : "Nodemon can also start the server"
}
Enter fullscreen mode Exit fullscreen mode

You will get this response from the server:

{
  "author": {
    "@ref": {
      "id": "301518866529387009",
      "collection": {
        "@ref": {
          "id": "Users",
          "collection": {
            "@ref": {
              "id": "collections"
            }
          }
        }
      }
    }
  },
  "title": "How to start nodejs server",
  "body": "Nodemon can also start the server.",
  "dateCreated": 1623813027616
}
Enter fullscreen mode Exit fullscreen mode

Deleting a blog

Now let's try deleting a blog from the document.
Create a new file routes/delete.blog.js and write the following code

const faunadb = require('faunadb');
const FaunaError = require('../errors/FaunaError.js');
const { Delete, Ref, Collection } = faunadb.query;
module.exports = {
  config: {
    isPrivate: true
  },
  schema: {
    params: {
      type: 'object',
      required: ['blogId'],
      properties: {
        blogId: {
          type: 'string',
          pattern: "[0-9]+"
        }
      }
    }
  },
  async handler(request, reply) {
    const blogId = request.params.blogId;
    console.log(blogId)
    const client = new faunadb.Client({
      secret: request.faunaSecret
    });
    try {
      // Delete the user document
      const resultDelete = await client.query(
        Delete(
          Ref(
            Collection('Blogs'),
            blogId
          )
        )
      );
      // Return the deleted document
      reply.send("blog deleted!");
    } catch (error) {
      throw new FaunaError(error);
    }
  }
};


fastify.get('/blogs/:blogId', require('./routes/delete.blog.js'));
Enter fullscreen mode Exit fullscreen mode

Now send a patch request to update our blog post.

http://localhost:3000/blogs/301522410208756231
Enter fullscreen mode Exit fullscreen mode

You will get this response from the server:

post deleted!
Enter fullscreen mode Exit fullscreen mode




Conclusion

Now you know how to build a fully functional blog app API using Node.js, Fauna and Fastify.
You can access the code snippet for this app here . If you have any issues, you can contact me via Twitter.

Discussion (0)