Creating a virtual classroom with the Zoom Video SDK

Introduction

Zoom Video SDK offers developers a host of ways for users to connect through a fully customizable video experience. So when I decided to build a virtual classroom using this tool, I was fully confident it'd be perfect for the job. With key features like UI and feature customization powered by Zoom’s core technology, the Video SDK provided a strong foundation for developing an online learning environment that would meet the needs of both teachers and students.

In this two-part guide, I’m going to walk through the process of building my virtual classroom application. We’ll look at how I built out my server to access my database and APIs (backend), and walk through my client-side, where I integrated the Video SDK package into a React application (front end).

In Part 1, we’ll review the server-side of my application, where I used:

  • Node.js with Express
  • Jsrsasign as my cryptography library
  • Axios as my HTTP client for requests

In Part 2, we’ll review my front end build, where I used:

  • React as my framework
  • Ant-Design as my UI library
  • TailwindCSS as a CSS library (for slight style tweaking)
  • Zoom Video SDK for media integration

Before moving on, I want to note that working with this guide requires a basic understanding of the above technologies, specifically Node.js, Express, & React.

Building My Server

To begin, I created a main folder, ‘classroom_example’, to house the entirety of my project. Next, I went on to build the server-side of the application. After moving into a sub-folder I created called 'server', I ran the command ‘npm init-y’ to build the package.json file.

The two base files within my server folder are ‘server.js’ and ‘router.js’, the former housing boiler-plate code for global route handling, and the latter directing specific routes based on the HTTP methods used. Let’s do a quick review of the folder/file structure of the back end & what's happening in each:

Server.js (/server/server.js)

  • Required in needed packages and assigned them to variables
  • Created our ‘app’ w. express and mounted necessary middleware to be used with all created routes
  • Set up our port (I used port ‘4000’ in my application)
  • Created default error handling
const express = require("express");
const app = express();
const router = require("./router");
app.use(express.json());
const cors = require("cors");
app.use(cors());
app.use("/", router);
app.use((err, req, res, next) => {
    const defaultErr = {
        log: err,
        status: 500,
        message: { err: "An error occurred" },
    };
    const errorObj = Object.assign({}, defaultErr, err);
    console.log(errorObj.log);
    return res.status(errorObj.status).json(errorObj.message);
});
app.listen(4000, () => {
    console.log("Listening on port 4000");
});

Router.js (/server/router.js)

  • Required in the files housing Controller functions, or the files used to get requested data from models
  • Created our router variable by assigning it the value of 'express.Router()'
  • Created our routes to send data to the frontend
const express = require("express");
const router = express.Router();
const userControllers = require("./Controllers/userControllers");
const sessionController = require("./Controllers/sessionControllers");
router.post("/generate", userControllers.generateToken, (req, res) => {
    res.status(200).json(res.locals.token);
});
router.post("/login", userControllers.verifyUser, (req, res) => {
    res.status(200).send(res.locals.loggedIn);
});
router.get(
    "/session",
    sessionController.getId,
    sessionController.getUsers,
    (req, res) => {
        res.status(200).send(res.locals.users);
    },
);
router.get(
    "/details",
    sessionController.getId,
    sessionController.getDetails,
    (req, res) => {
        res.status(200).send(res.locals.details);
    },
);

Additionally, within my server folder, I have a ‘Models’ folder and a ‘Controllers’ folder. In the Models folder, I’m connecting my PostgreSQL database to my codebase (server/Models/userModels.js). Using Node-Postgres, I created a new Pool instance to store my connection string and reduce connection overhead by maintaining established connections. The exported object from this file returns the output of calling the query method on our created pool, using the passed in parameters as arguments ('query' is a built-in method for the Pool instance). This is what we’ll import and use in our controller functions.

const { Pool } = require("pg");
const connectionString = "";
const pool = new Pool({
    connectionString: connectionString,
});
module.exports = {
    query: (text, params, callback) => {
        console.log("executed query", text);
        return pool.query(text, params, callback);
    },
};

Moving on, our controller functions are housed in two distinct files; one for user controllers and one for session controllers. Let’s break down what’s happening in each file in the next section.

User Controllers

In userControllers.js (/server/Controllers/userControllers.js), I needed to create a JWT signature. To do this, I used jsrsasign as my cryptography library, and dotenv to read-in my SDK credentials stored as environment variables. To access and query my database, I imported my created object from my ‘userModels’ file and assigned it to the variable ‘db’.

After I created my empty object to store my created methods, I wrote out my first one, ‘userControllers.generateToken’. It takes in the normal middleware parameters, ‘req’ for my request body, ‘res’ for my response object, & ‘next’ to return out to the next function. This method contains a try…catch block, in which I’ll be using this functionality to generate my JWT signature and then saving it to my response object, as shown below (note, to access my user values, I destructured my request body object, which contains elements sent from the frontend). If the promise does not resolve, my catch block will return to next() with the given error as an argument.

signature = KJUR.jws.JWS.sign("HS256", sHeader, sPayload, sdkSecret);
res.locals.token = signature;
return next();

The next method I created was ‘userControllers.verifyUser’, which is used to verify the existence of a user in the database. In this method, I’ll need to query my database using SQL. I placed my received username and password inside of an array and assigned that array to the variable ‘values’, which will be used as an argument later on. Next, I created my query string, which is a combination of SQL statements used to query my database for specified information. I'll further break it down below.

const { username, password } = req.body;
const values = [username, password];
const queryString =
    "SELECT * FROM students WHERE username = $1 AND password = $2";
  • SELECT * FROM students - SQL statement to select all columns from the ‘students’ table
  • WHERE - look for columns where values match the ones given in the query string
  • username = $1 AND password = $2 - only select columns where the username value is equal to the first value in the passed in argument (discussed further below) and the password value is equal to the second value passed in. Both conditions must be true.

Next, using the variable I created to store my database, I called the ‘query’ method on it, passing my created query string and my array ‘values’. This returns a promise, so I used a ‘.then’ statement to handle my resolved output. To know whether or not the user exists in my database, I looked at my returned object and paid attention to the value of ‘rowCount’. If a row was returned and the count is 1, that means a user was found with the matching credentials. Otherwise, the row count would be 0, indicating no user with the inputted username and password exists. The returned object is shown below:

> classroom@1.0.0 start
> node server.js
Listening on port 4000
executed query SELECT * FROM students WHERE username = $1 AND password = $2
Result {
  command: 'SELECT',
  rowCount: 1,
  oid: null,
  rows: [
    {
      firstname: 'tester',
      lastname: 'test',
      username: 'zoomUser',
      password: '1234',
      status: 'teacher'
    }
  ],

To verify the length of 'rowCount' in my code, I use the length method on the 'rows' array within my results object. If the method evaluates true, I know that a user with those credentials exists within the database (code seen in screenshot below).

After verifying the user exists, I saved the information I wanted to send to the frontend in my response object. In this case, I want to know whether or not the user is logged in and what their username is. Here’s what creating and saving those properties look like:

db.query(queryString, values).then((data) => {
    console.log(data);
    if (data.rows.length) {
        res.locals.loggedIn = true;
        res.locals.username = username;
        return next();
    } else {
        console.log("no user");
        res.locals.loggedIn = false;
        res.locals.username = "";
        return next();
    }
});

You’ll see, in the image above, that we’re setting ‘res.locals.loggedIn’ to false if ‘data.rows.length’ evaluates to false, meaning that no user with the given credentials exists. Lastly, I used my .catch block to return any received error into my next middleware function.

This wraps up our methods for our user controllers. Now, let’s move on to our session controllers.

Session Controllers

In this file (/server/Controllers/sessionControllers.js), I created the methods needed to get session details using the Video SDK API, and used Axios as my HTTP library to request information.

Before we dive into the methods created, I want to review the requirements for working with our Video SDK API. In the same way Video SDK Credentials are required to generate your JWT to join sessions, you’ll also need to use your Video SDK API Credentials to generate a JWT to access and work with the APIs. When logged into your Video SDK account on the marketplace, navigate to the build section and scroll down to ‘API Keys’. Here, you’ll see a dropdown to view your JWT token. To access the APIs, you’ll need to use this token as a bearer token within your request headers, as shown below.

 try {
    sessionId = await axios({
      method: 'GET',
      url,
      header: {
        "Authorization" : `Bearer ${bearerToken}`
      }
    })
 }

You can customize the expiration date for your token, as well. The lower the time, the more secure.

Moving on to our functionality, the first method I wrote out is ’sessionController.getId’, which I used to get the current session ID. (Note, you can also use the built-in client method, ‘getSessionInfo’, to access the session ID on the front end and then send that to the backend for use. For this project, I chose to retrieve the information on the backend for cohesion and demonstrative purposes). Using our API reference documentation, I found the appropriate endpoint to make my call to, which is shown below:

sessionController.getId = async (req, res, next) => {
  const url = 'https://api.zoom.us/v2/videosdk/sessions?type=past&from=2023-05-23&to=2023-06-15';

Following the steps I saw outlined in our documentation, I customized my request parameters to select details from past sessions, specifying the date range I wanted. Once I retrieved my session information, I filtered through the received object to find the session ID and save it to my res.locals object. This way, it was ready for use in other methods, which we’ll look at next.

sessionId = sessionId.data;
res.locals.sessionId = sessionId.sessions[0].id;

The second method within this controller file is my ‘getUsers’ method to list my sessions users. Let’s take a look at the endpoint I used and break down how I customized it:

const url = `https://api.zoom.us/v2/videosdk/sessions/${res.locals.sessionId}/users?type=past&page_size=30`;
  • I used my stored session ID to specify the session I wanted to garner details for
  • I set the type as ‘past’, to access a past session, instead of a live one
  • I set the page size to 30 to only return this amount of records within one call

To store the user details I’ll need on the frontend, I looped through my returned object and pushed the necessary data into a previously created array.

result = result.data.users;
    for (let i =0; i < result.length; i++) {
        users.push({
          userId: result[i].id,
          name: result[i].name,
          TOA: result[i].join_time
        })
      }
      res.locals.users = users;
      return next();
    }

The last method in this file is ‘getDetails’, which I used to get all the session details and send them to the frontend. Following the same methodology as the last two methods, I made a call to the appropriate endpoint and stored the returned object in the res.locals object.

sessionController.getDetails = async (req, res, next) => {
    const url = `https://api.zoom.us/v2/videosdk/sessions/${res.locals.sessionId}?type=past`;
    try {
        let result = await axios({
            method: "GET",
            url,
            headers: {
                Authorization: `Bearer ${bearerToken}`,
            },
        });
        result = result.data;
        res.locals.details = result;
        return next();
    } catch (err) {
        return next({
            log: `Error in sessionController.getDetails: ${err}`,
            status: 500,
            message: { err: "Error has occurred" },
        });
    }
};

Now that we’ve finished out our controller functions for both users and sessions, let’s jump back into router.js and see how everything flows together.

Router.js

router.post("/generate", userControllers.generateToken, (req, res) => {
    res.status(200).json(res.locals.token);
});
router.post("/login", userControllers.verifyUser, (req, res) => {
    res.status(200).send(res.locals.loggedIn);
});
router.get(
    "/session",
    sessionController.getId,
    sessionController.getUsers,
    (req, res) => {
        res.status(200).send(res.locals.users);
    },
);
router.get(
    "/details",
    sessionController.getId,
    sessionController.getDetails,
    (req, res) => {
        res.status(200).send(res.locals.details);
    },
);
  • The first route is a post request used to generate our user’s JWT token, and will only go through one function, userControllers.generateToken, before sending the stored token back to the front end.
  • Our second route is also a post request, where we’ll receive the user’s login credentials, run them through userControllers.verifyUser, and send the stored ‘res.locals.loggedIn’, that we saved in that function, to the front end.
  • Our third route is a get request, where we’ll first get the session ID, then go through sessionController.getUsers to get the users, then send to the frontend the created array of user information that was saved in our res.locals object.
  • The fourth and final route is another get request, where we’ll go through sessionController.getId again to make sure we have the most current information, then go to sessionController.getDetails. We’ll then send the stored details to the front end.

Conclusion

That’s a wrap on building out the server-side of the application! To review, we:

  • Set up our server.js and router.js files
  • Set up our model to gain and maintain access to our created database
  • Created our controller functions to access and store data
  • Sent stored data back to the frontend in our router