Building a Fullstack App with ReactJS, NodeJS, ExpressJs and Redis-OM

Building a Fullstack App with ReactJS, NodeJS, ExpressJs and Redis-OM

Featured on daily.dev

Overview

According to Meik Wiking (author of The Art of Making Memories), happy memories are essential to our mental health. They strengthen our sense of identity and purpose and bond our relationships. Happy memories are an important ingredient in present happiness. As such, this gave birth to my project, Memories App which allows everyone to document their memories at any point in time.

Without Further ado, let's navigate into the project details.

Preamble Redis is a NoSQL database that's loved for its simplicity and speed. In this blogpost, We will be building a Fullstack app with Redis Database for storing the data. We'll be building our model with Redis-OM capabilities which allow create, retrieve, update, and carry out a full-text search. It’s totally fine if you are not familiar with the Redis database.

To start with:

What is Redis? Well, Redis is an in-memory key-value store that is often used as a cache to make traditional databases faster. However, it has evolved into a multimodel database capable of full-text search, graph relationships, AI workloads, and more.

What is RedisJSON? RedisJSON is a Redis module that provides JSON support in Redis. RedisJSON lets your store, update, and retrieve JSON values in Redis just as you would with any other Redis data type. RedisJSON also works seamlessly with RediSearch to let you index and query your JSON documents. It is the best way to work with the Redis database.

What is Redis-OM Redis OM (pronounced REDiss OHM) is a library that provides object mapping for Redis—that's what the OM stands for... object mapping. It maps Redis data types — specifically Hashes and JSON documents — to JavaScript objects. And it allows you to search over these Hashes and JSON documents. It uses RedisJSON and RediSearch to do this.

while RedisJSON adds a JSON document data type and the commands to manipulate it, RediSearch adds various search commands to index the contents of JSON documents and Hashes.

Let's build with Redis and take the advantages of these Redis-OM capabilities!

Setting up

Start up Redis First, we'll set up our environment. Then, for development purposes, we'll use Docker to run Redis Stack:

docker run -p 10001:6379 -p 13333:8001 redis/redis-stack:latest

This starts your Redis server, once this is started, proceed to build the backend server

Intitialize your application

  • In your project directory, navigate to the terminal and run the following commands:
mkdir server
cd server
npm run init -y
  • This creates a package.json and package-lock.json files in your server folder, now you can start installing your dependencies. We will install the following packages: redis-om, cors, express, nodemon along with other dependencies

  • Run the following command to install the dependencies

npm i cors express nodemon redis-om bcrypt body-parser colors dotenv express express-validator jsonwebtoken nodemon redis redis-om
  • Let's create our env file before we proceed to the last part of the app create .env in the server folder and add
PORT=3005
REDIS_HOST="redis://localhost:6379"

JWT_TOKEN_SECRET= "add your preferred jwt secret"
JWT_EXPIRE="add the expiry"
  • With dotenv installed as part of our dependencies, we can start using our environmental variable data in our application.

Create and connect a Redis Client

  • To create and connect a Redis client, Create config folder in the server folder and create a file named connectToRedis.js
config/connectToRedis.js

import { Client } from "redis-om";
import dotenv from "dotenv";
const url = process.env.REDIS_HOST;
let client;
try {
  client = await new Client().open(url);
console.log("##########################################################");
  console.log("#####            REDIS STORE CONNECTED               #####");
  console.log("##########################################################\n");
} catch (err) {
  console.log(`Redis error: ${err}`.red.bold);
}
export default client;

Create the models/repositories

  • Create a folder named model in the server folder and add the two files named post.js and user.js respectively.
model/post.js 

import { Entity, Schema } from "redis-om";
import client from "../config/connectToRedis.js";
class Post extends Entity {}

const postSchema = new Schema(
  Post,
  {
    title: { type: "text" },
    message: { type: "text" },
    name: { type: "string" },
    creator: { type: "string" },
    tags: { type: "text" },
    selectedFile: { type: "string" },
    likes: { type: "string[]" },
    comments: { type: "string[]" },
    createdAt: { type: "date", sortable: true },
  },
  {
    dataStructure: "JSON",
  }
);

export const postRepository = client.fetchRepository(postSchema);
await postRepository.createIndex();
model/user.js

import { Entity, Schema } from "redis-om";
import client from "../config/connectToRedis.js";

class User extends Entity {}

const userSchema = new Schema(
  User,
  {
    name: {
      type: "string",
    },
    email: {
      type: "string",
    },
    password: {
      type: "string",
    },
  },
  {
    dataStructure: "JSON",
  }
);

export const userRepository = client.fetchRepository(userSchema);
await userRepository.createIndex();
  • For both schemas, we defined an entity. An Entity is the class that holds your data when you work with it — the thing being mapped to. It is what you create, read, update, and delete. Any class that extends Entity is an entity.
  • We also define our schemas for both; A schema defines the fields on your entity, their types, and how they are mapped internally to Redis. By default, entities map to JSON documents.

Note: in the field type, A text field is a lot like a string. The difference is that string fields can only be matched on their whole value — no partial matches — and are best for keys while text fields have full-text search enabled on them and are optimized for human-readable text.

  • We also created a repository for each. A Repository is the main interface into Redis OM. It gives us the methods to read, write, and remove a specific Entity

and lastly...

  • We created an index so we would be able to search for the data in each repositories. We do that by calling .createIndex(). If an index already exists and it's identical, this function won't do anything. If it's different, it'll drop it and create a new one

Set up the Routes

  • Create a route folder an add both posts.js and users.js file which contain the routes for our application. It should like what we have below:
routes/posts.js 

import express from "express";
const router = express.Router();
import { getPosts, getPost, getPostsBySearch, createPost, updatePost, deletePost, likePost, commentPost, getMyPosts, getSuggestedPosts, } from "../controller/posts.js";
import validator from "../middleware/validator.js";
import auth from "../middleware/auth.js";
import schema from "../validation/post.js";
const { postSchema } = schema;

router.route("/").get(auth, getMyPosts).post(auth, validator(postSchema), createPost);
router.route("/all").get(getPosts);
router.get("/search", getPostsBySearch);
router.get("/suggestion", getSuggestedPosts);
router.route("/:id").patch(auth, updatePost).delete(auth, deletePost);
router.get("/:id", getPost);
router.patch("/:id/comment", commentPost);
router.patch("/:id/like", auth, likePost);

export default router;
routes/users.js 

import express from "express";

const router = express.Router();

import { signin, signup } from "../controller/user.js";
import validator from "../middleware/validator.js";
import schema from "../validation/user.js";
const { userSchema } = schema;

router.route("/signin").post(signin);
router.route("/signup").post(validator(userSchema), signup);

export default router;

Note, validators are done with express validator, so we imported the post and user validation schema and ensure our data are first validated when a request is sent to the endpoint. You can have access to necessary validation files here Validation Files and validator middleware

Set up the Index.js File

Create Index.js file in the server folder

index.js

import express from "express";
import bodyParser from "body-parser";
import cors from "cors";
import dotenv from "dotenv";
import colors from "colors";
import client from "./config/connectToRedis.js";
import postRoutes from "./routes/posts.js";
import { errorHandler, notFound } from "./middleware/error.js";
import userRoutes from "./routes/users.js";

const app = express();

dotenv.config();

//Body Parser
app.use(bodyParser.json({ limit: "30mb", extended: true }));
app.use(bodyParser.urlencoded({ limit: "30mb", extended: true }));

app.use(cors());

app.get("/", (req, res) => {
  res.json({ message: "Hello to Memories API" });
});
app.use("/posts", postRoutes);
app.use("/user", userRoutes);

const PORT = process.env.PORT || 5000;

const server = app.listen(PORT, () => {
  console.log("##########################################################");
  console.log("#####               STARTING SERVER                  #####");
  console.log("##########################################################\n");
  console.log(`server running on → PORT ${server.address().port}`.yellow.bold);
});

process.on("uncaughtException", (error) => {
  console.log(`uncaught exception: ${error.message}`.red.bold);
  process.exit(1);
});

process.on("unhandledRejection", (err, promise) => {
  console.log(`Error ${err.message}`.red.bold);
  server.close(() => process.exit(1));
});

app.use(notFound);

app.use(errorHandler);

Here, we consumed different middlewares and import our connected Redis client in the file and also the created routes.

ooops, we are getting there comrade!, onto the last part of our application!,

Set up the Controllers

Here is where the logic of our application resides. For the user registration, We will be using JWT to sign the credentials and bycrypt to encrypt the password before storing them in our database.

  • From the /register route, we will:
    • Get user input.
    • Validate if the user already exists.
    • Encrypt the user password.
    • Create a user in our database.
    • And finally, create a signed JWT token.
controller/user.js


export const signup = async (req, res) => {
  const { firstName, lastName, email, confirmPassword } = req.body;
  const existingUser = await userRepository.search().where("email").is.equalTo(email).return.first();
  //check if user already registered with the email
  if (existingUser) {
    return res.status(400).json({ message: "A user already registered with the email." });
  }
  if (req.body.password !== confirmPassword) {
    return res.status(400).json({ message: "Passwords don't match." });
  }

  //hash password
  const hashedPassword = await bcrypt.hash(req.body.password, 12);
  const user = await userRepository.createAndSave({ name: `${firstName} ${lastName}`, email, password: hashedPassword });

  const token = jwt.sign({ email: user.email, id: user.entityId }, process.env.JWT_TOKEN_SECRET, {
    expiresIn: process.env.JWT_EXPIRE,
  });
  const { entityId, password, ...rest } = user.toJSON();
  const data = { id: user.entityId, ...rest };
  res.status(200).json({ result: data, token });
};
  • Using Postman to test the endpoint, we'll get the below response after a successful registration.

Signup response

Throughout this application, note how id replaces entityId provided for us by Redis-OM to match a traditional response.

 const { entityId, password, ...rest } = user.toJSON();
 const data = { id: user.entityId, ...rest };
  • For the Login Route
    • Get user input.
    • Authenticate the user.
    • And finally, create and send a signed JWT token.
controller/user.js

export const signin = async (req, res) => {
  const { email } = req.body;
  const existingUser = await userRepository.search().where("email").is.equalTo(email).return.first();
  //check if user exists
  if (!existingUser) {
    return res.status(404).json({ message: "User not found." });
  }
  //check for correct password
  const isPasswordCorrect = await bcrypt.compare(req.body.password, existingUser.password);
  if (!isPasswordCorrect) {
    return res.status(404).json({ message: "invalid Credentials" });
  }
  //create auth token
  const token = jwt.sign({ email: existingUser.email, id: existingUser.entityId }, process.env.JWT_TOKEN_SECRET, {
    expiresIn: process.env.JWT_EXPIRE,
  });
  const { entityId, password, ...rest } = existingUser.toJSON();
  const data = { id: existingUser.entityId, ...rest };
  res.status(200).json({ result: data, token });
};
  • Using Postman to test the endpoint, we'll get the below response after a successful login.

Login Response

😊 And the user can start creating his or her memories

okay, let's dive into the post controller...

Creating a Post

controller/post.js

export const createPost = async (req, res) => {
  const post = req.body;
  const newPost = await postRepository.createAndSave({
    ...post,
    creator: req.userId,
    createdAt: new Date().toISOString(),
  });

  const { entityId, ...rest } = newPost.toJSON();
  res.status(201).json({ data: { id: newPost.entityId, ...rest } });
};

Create post response

Get Paginated Posts

controller/post.js

export const getPosts = async (req, res) => {
  const { page } = req.query;
  const limit = 8;
  const offset = (page - 1) * limit;
  if (!page) {
    res.status(400).json({ message: "Enter count and offset" });
  }
  const posts = await postRepository.search().sortDescending("createdAt").return.page(offset, limit);
  const postsCount = await postRepository.search().sortDescending("createdAt").return.count();
  const newPosts = posts.map((item) => {
    const { entityId, ...rest } = item.toJSON();
    return { id: item.entityId, ...rest };
  });

  res.status(200).json({ data: newPosts, currentPage: Number(page), numberOfPages: Math.ceil(postsCount / limit) });
};

Get Paginated posts

Get a post

controller/post.js

export const getPost = async (req, res) => {
  const data = await postRepository.fetch(req.params.id);
  if (!data.title) {
    return res.status(404).json({ message: "No post with that id" });
  }
  const { entityId, ...rest } = data.toJSON();
  res.status(200).json({ data: { id: data.entityId, ...rest } });
};

get a post

Delete a post

controller/post.js

export const deletePost = async (req, res) => {
  const post = await postRepository.fetch(req.params.id);
  if (!post.title) {
    return res.status(404).json({ message: "No post with that id" });
  }
  if (req.userId !== post.creator) {
    return res.status(400).json({ message: "Unauthorized, only creator of post can delete" });
  }
  await postRepository.remove(req.params.id);
  res.status(200).json({ message: "post deleted successfully" });
};

Delete Post response

Like a post

controller/post.js

export const likePost = async (req, res) => {
  let post = await postRepository.fetch(req.params.id);
  if (!post.title) {
    return res.status(400).json({ message: "No post with that id" });
  }
  if (!post.likes) {
    post.likes = [];
  }
  const index = post.likes.findIndex((id) => id === String(req.userId));
  if (index === -1) {
    post.likes = [...post.likes, req.userId];
  } else {
    post.likes = post?.likes?.filter((id) => id !== String(req.userId));
  }
  await postRepository.save(post);
  const { entityId, ...rest } = post.toJSON();
  res.status(200).json({ data: { id: post.entityId, ...rest } });
};

Like Post Response

  • And we would put an halt to request to our post endpoints here.

Redis Cloud Platform

  • On a final note, let's navigate through the Redis Cloud Platform
  • Create an account on Redis and log in.
  • Navigate to the dashboard to create a new subscription. You can start with the free tier and upgrade later depending on your app usage.
  • Create a new Database called Memories App Create database

Dashboard

Note the endpoints are consumed in a frontend app prepared along with this project

Submission Category:

  • MEAN/MERN Maverick: Redis as a primary database instead of MongoDB (i.e. replace “M” in MEAN/MERN with “R” for Redis).

Technologies Used

  • JS/Node.js
  • Express Js
  • JSON Web Token
  • ReactJs, Redux
  • Material UI

Deployment

To make deploys work, you need to create free account on Redis Cloud

Heroku

Deployed Backend Link

Netlify

Frontend Memories App

Additional Resources / Info

Screenshots

Image1

Image2 Image3 Image4 Image5

Conclusion

And here we come to the end of the app where we have learned how to use NodeJS and Redis OM to create APIs that perform crud operations and take advantage of RedisJson capabilities by storing JSON documents and retrieving them and RedisSearch capabilities to perform queries and full_text_search.