loading...

React/Redux application with Azure DevOps: Part 6 express backend and database

kenakamu profile image Kenichiro Nakamura ・9 min read

In the previous post, I try function component with hook and Jest snapshot testing.

In this article, I add backend server and database as most application require them anyway.

express

There might be many choices for backend server, but as I am familiar with express, I am using it.

The first step is to add express server portion and confirm it works without any breaks.

Install and configure express

Whenever I create express application, I use express-generator or express-generator-typescript. It provides following features.

  • Default view page
  • Routing setup
  • Data Access Objects (DAO) layer and mock database with strong typed model
  • Unit tests
  • Logging

1. Inside "my-react-redux-app", run the generator to create backend.

npx express-generator-typescript react-backend

2. I can see express application is added.
Alt Text

3. By default, both react and express runs on port 3000. To avoid the port conflict, change default port of express by updating /env/development.env.

# Server
PORT=3001
HOST=localhost

4. Run the express application.

cd react-backend
npm install
npm start

Alt Text

5. Finally, add proxy element in package.json of react so that it can communicate to backend from react.
Alt Text

Database

There are so many choices for database. I use Azure Redis Cache this time but basically you can use any database technologies.

1. Provision Azure Redis Cache by following steps found here

2. Install modules.

npm install redis bluebird
npm install --save-dev @types/redis @types/bluebird

3. Add redis information inside /env/development.env as production.env as I didn't setup separate databases, which I should if I have enough money :)

# Redis
REDISCACHEHOSTNAME=<your_redis>.redis.cache.windows.net
REDISCACHEKEY=<your_key>

Add Vote router

By default, the template has Users routing which returns users.
Alt Text

I add vote router which CRUD vote from redis cache.

1. First thing first, define model. Add Vote.ts under react-backend/src/entities.

/// Vote.ts

export interface IVote {
    id: string;
    votes: number[];
    candidates: string[]
}

class Vote implements IVote {

    public id: string;
    public votes: number[];
    public candidates: string[];

    constructor(id:string, votes:number[] = [], candidates:string[] = []) {
        this.id = id;
        this.votes = votes;
        this.candidates = candidates;
    }
}

export default Vote;

2. Add data access object next. Add Vote folder under react-backend/src/daos/Vote and add VoteDao.ts inside. Data access implementation goes here.

  • Use bluebird to enable async/await pattern
  • As del function use OverloadedCommand and promisify cannot select which one to use, I explicitly specify it.
/// VoteDao.ts

import { IVote } from '@entities/Vote';
import redis from 'redis';
import bluebird from 'bluebird';

export interface IVoteDao {
    getAsync: (id: string) => Promise<IVote | null>;
    addAsync: (user: IVote) => Promise<void>;
    updateAsync: (user: IVote) => Promise<void>;
    deleteAsync: (id: string) => Promise<void>;
}

const redisClient : redis.RedisClient = redis.createClient(6380, process.env.REDISCACHEHOSTNAME,
    {auth_pass: process.env.REDISCACHEKEY, tls: {servername: process.env.REDISCACHEHOSTNAME}});

// del has many overload, so specify one here so that I can use in promisify
const del: (arg1:string|string[], cb?:redis.Callback<number>) => boolean =  redisClient.del;
const getAsync = bluebird.promisify(redisClient.get).bind(redisClient);
const setAsync = bluebird.promisify(redisClient.set).bind(redisClient);
const delAsync = bluebird.promisify(del).bind(redisClient);

class VoteDao implements IVoteDao {

    /**
     * @param id
     */
    public async getAsync(id: string): Promise<IVote | null> {
        return JSON.parse(await getAsync(id)) as IVote;
    }

    /**
     *
     * @param vote
     */
    public async addAsync(vote: IVote): Promise<void> {
        await setAsync(vote.id, JSON.stringify(vote));
    }

    /**
     *
     * @param vote
     */
    public async updateAsync(vote: IVote): Promise<void> {
        await setAsync(vote.id, JSON.stringify(vote));
    }

    /**
     *
     * @param id
     */
    public async deleteAsync(id: string): Promise<void> {
        await delAsync(id);
    }
}

export default VoteDao;

3. As service is implemented, let's add router. Add Votes.ts in react-backend/src/routes.

  • I used Users.ts as starting point but I changed the URI pattern to match REST spec
/// Votes.ts

import { Request, Response, Router } from 'express';
import { BAD_REQUEST, CREATED, OK } from 'http-status-codes';
import { ParamsDictionary } from 'express-serve-static-core';

import VoteDao from '@daos/Vote/VoteDao';
import logger from '@shared/Logger';
import { paramMissingError } from '@shared/constants';

// Init shared
const router = Router();
const voteDao = new VoteDao();

/******************************************************************************
 *                      Get a Vote - "GET /api/votes/1"
 ******************************************************************************/
router.get('/:id', async (req: Request, res: Response) => {
    try {
        const { id } = req.params as ParamsDictionary;
        const vote = await voteDao.getAsync(id);
        return res.status(OK).json({vote});
    } catch (err) {
        logger.error(err.message, err);
        return res.status(BAD_REQUEST).json({
            error: err.message,
        });
    }
});

/******************************************************************************
 *                       Add One - "POST /api/votes"
 ******************************************************************************/
router.post('/', async (req: Request, res: Response) => {
    try {
        const { vote } = req.body;
        if (!vote) {
            return res.status(BAD_REQUEST).json({
                error: paramMissingError,
            });
        }
        await voteDao.addAsync(vote);
        return res.status(CREATED).json({vote});
    } catch (err) {
        logger.error(err.message, err);
        return res.status(BAD_REQUEST).json({
            error: err.message,
        });
    }
});

/******************************************************************************
 *                       Update - "PUT /api/votes"
 ******************************************************************************/
router.put('/', async (req: Request, res: Response) => {
    try {
        const { vote } = req.body;
        if (!vote) {
            return res.status(BAD_REQUEST).json({
                error: paramMissingError,
            });
        }
        vote.id = Number(vote.id);
        await voteDao.updateAsync(vote);
        return res.status(OK).json({vote});
    } catch (err) {
        logger.error(err.message, err);
        return res.status(BAD_REQUEST).json({
            error: err.message,
        });
    }
});

/******************************************************************************
 *                    Delete - "DELETE /api/votes/:id"
 ******************************************************************************/
router.delete('/:id', async (req: Request, res: Response) => {
    try {
        const { id } = req.params as ParamsDictionary;
        await voteDao.deleteAsync(id);
        return res.status(OK).end();
    } catch (err) {
        logger.error(err.message, err);
        return res.status(BAD_REQUEST).json({
            error: err.message,
        });
    }
});

/******************************************************************************
 *                                     Export
 ******************************************************************************/
export default router;

That's it for backend. Run the application by npm start.

npm start:dev

You can use any tool to test it. I usually use Postman, but curl, PowerShell, or anything else just works as it's REST endpoint.

Unit Test

The template provides Users.spec.ts under spec folder. I take some code from there to write unit test for Vote router.

1. Install modules to mock redis. Make sure you are in react-backend folder.

npm install --save-dev redis-mock @types/redis-mock

2. Add Votes.spec.ts in spec folder.

  • Use redis-mock to mock the redis
  • Use spyOn to mock the function behavior
/// Votes.spec.ts

import supertest from 'supertest';
import { BAD_REQUEST, CREATED, OK } from 'http-status-codes';
import { Response, SuperTest, Test } from 'supertest';

import app from '@server';
import VoteDao from '@daos/Vote/VoteDao';
import Vote from '@entities/Vote';
import { pErr } from '@shared/functions';
import { paramMissingError } from '@shared/constants';
import redis from 'redis';
import redisMock from 'redis-mock';

describe('Vote Routes', () => {

    const votesPath = '/api/votes';
    const getVotePath = `${votesPath}/:id`;
    const addVotePath = `${votesPath}`;
    const updateVotePath = `${votesPath}`;
    const deleteVotePath = `${votesPath}/:id`;

    let agent: SuperTest<Test>;

    beforeAll((done) => {
        agent = supertest.agent(app);
        spyOn(redis, 'createClient').and.callFake(redisMock.createClient);
        spyOn(redis.RedisClient.prototype, 'ready_check').and.callFake(()=>{});

        done();
    });

    describe(`"GET:${getVotePath}"`, () => {

        it(`should return a JSON object with a vote and a status code of "${OK}" if the
            request was successful.`, (done) => {
            const vote = new Vote('1', [0, 0], ['cat', 'dog']);
            spyOn(VoteDao.prototype, 'getAsync').and.returnValue(Promise.resolve(vote));

            const callApi = (id: string) => {
                return agent.get(getVotePath.replace(':id', id));
            };

            callApi('1')
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(OK);
                    // Caste instance-objects to 'Vote' objects
                    const retVote = new Vote(res.body.vote.id,res.body.vote.votes,res.body.vote.candidates);
                    expect(retVote).toEqual(vote);
                    expect(res.body.error).toBeUndefined();
                    done();
                });
        });

        it(`should return a JSON object containing an error message and a status code of
            "${BAD_REQUEST}" if the request was unsuccessful.`, (done) => {

            const errMsg = 'Could not fetch Votes.';
            spyOn(VoteDao.prototype, 'getAsync').and.throwError(errMsg);

            agent.get(getVotePath)
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(errMsg);
                    done();
                });
        });
    });

    describe(`"POST:${addVotePath}"`, () => {

        const callApi = (reqBody: object) => {
            return agent.post(addVotePath).type('form').send(reqBody);
        };

        const voteData = {
            vote: new Vote('1', [0, 0], ['cat', 'dog'])
        };

        it(`should return a status code of "${CREATED}" if the request was successful.`, (done) => {

            spyOn(VoteDao.prototype, 'addAsync').and.returnValue(Promise.resolve());

            agent.post(addVotePath).type('form').send(voteData) // pick up here
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(CREATED);
                    expect(res.body.error).toBeUndefined();
                    done();
                });
        });

        it(`should return a JSON object with an error message of "${paramMissingError}" and a status
            code of "${BAD_REQUEST}" if the Vote param was missing.`, (done) => {

            callApi({})
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(paramMissingError);
                    done();
                });
        });

        it(`should return a JSON object with an error message and a status code of "${BAD_REQUEST}"
            if the request was unsuccessful.`, (done) => {

            const errMsg = 'Could not add Vote.';
            spyOn(VoteDao.prototype, 'addAsync').and.throwError(errMsg);

            callApi(voteData)
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(errMsg);
                    done();
                });
        });
    });

    describe(`"PUT:${updateVotePath}"`, () => {

        const callApi = (reqBody: object) => {
            return agent.put(updateVotePath).type('form').send(reqBody);
        };

        const voteData = {
            vote: new Vote('1', [0, 0], ['cat', 'dog'])
        };

        it(`should return a status code of "${OK}" if the request was successful.`, (done) => {

            spyOn(VoteDao.prototype, 'updateAsync').and.returnValue(Promise.resolve());

            callApi(voteData)
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(OK);
                    expect(res.body.error).toBeUndefined();
                    done();
                });
        });

        it(`should return a JSON object with an error message of "${paramMissingError}" and a
            status code of "${BAD_REQUEST}" if the Vote param was missing.`, (done) => {

            callApi({})
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(paramMissingError);
                    done();
                });
        });

        it(`should return a JSON object with an error message and a status code of "${BAD_REQUEST}"
            if the request was unsuccessful.`, (done) => {

            const updateErrMsg = 'Could not update Vote.';
            spyOn(VoteDao.prototype, 'updateAsync').and.throwError(updateErrMsg);

            callApi(voteData)
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(updateErrMsg);
                    done();
                });
        });
    });

    describe(`"DELETE:${deleteVotePath}"`, () => {

        const callApi = (id: string) => {
            return agent.delete(deleteVotePath.replace(':id', id));
        };

        it(`should return a status code of "${OK}" if the request was successful.`, (done) => {

            spyOn(VoteDao.prototype, 'deleteAsync').and.returnValue(Promise.resolve());

            callApi('1')
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(OK);
                    expect(res.body.error).toBeUndefined();
                    done();
                });
        });

        it(`should return a JSON object with an error message and a status code of "${BAD_REQUEST}"
            if the request was unsuccessful.`, (done) => {

            const deleteErrMsg = 'Could not delete Vote.';
            spyOn(VoteDao.prototype, 'deleteAsync').and.throwError(deleteErrMsg);

            callApi('1')
                .end((err: Error, res: Response) => {
                    pErr(err);
                    expect(res.status).toBe(BAD_REQUEST);
                    expect(res.body.error).toBe(deleteErrMsg);
                    done();
                });
        });
    });
});

3. Run the test.

npm test

Alt Text

Update Test settings

The current test setup does following.

  • Use nodemon to run the test and keep watching the spec folder
  • Result is displayed to console only

I need to change the behavior so that it works with CI pipeline well.

1. Add additional reporter to jasmine so that it can generate JUnit result. Make sure to run npm install in react-backend folder.

npm install --save-dev jasmine-reporters

2. Update index.ts under spec folder. This is the code to control jasmine.

  • Accept --ci parameter
  • Use JUnitXmlReporter and save it to current directly
  • Exit jasmine once completed
/// index.ts

import find from 'find';
import Jasmine from 'jasmine';
import dotenv from 'dotenv';
import commandLineArgs from 'command-line-args';
import logger from '@shared/Logger';
var reporters = require('jasmine-reporters');

// Setup command line options
const options = commandLineArgs([
    {
        name: 'testFile',
        alias: 'f',
        type: String,
    },
    {
        name: 'ci',
        type: Boolean
    }
]);

// Set the env file
const result2 = dotenv.config({
    path: `./env/test.env`,
});
if (result2.error) {
    throw result2.error;
}

// Init Jasmine
const jasmine = new Jasmine(null);
var junitReporter = new reporters.JUnitXmlReporter({
    savePath: __dirname,
    consolidateAll: false
});
jasmine.addReporter(junitReporter);
// Set location of test files
jasmine.loadConfig({
    random: true,
    spec_dir: 'spec',
    spec_files: [
        './**/*.spec.ts',
    ],
    stopSpecOnExpectationFailure: false,
});

// On complete callback function
jasmine.onComplete((passed: boolean) => {
    if (passed) {
        logger.info('All tests have passed :)');
    } else {
        logger.error('At least one test has failed :(');
    }
    if (options.ci) {
        jasmine.exitCodeCompletion(passed);
    }
});

// Run all or a single unit-test
if (options.testFile) {
    const testFile = options.testFile;
    find.file(testFile + '.spec.ts', './spec', (files) => {
        if (files.length === 1) {
            jasmine.specFiles = [files[0]];
            jasmine.execute();
        } else {
            logger.error('Test file not found!');
        }
    });
} else {
    jasmine.execute();
}

3. Update package config test script so that I can bypass nodemon. I keep the old one by tagging old.

"scripts": {
  "build": "node ./util/build.js",
  "lint": "tslint --project \"tsconfig.json\"",
  "start": "node -r module-alias/register ./dist",
  "start:dev": "nodemon --config nodemon.json",
  "test:old": "nodemon --config nodemon.test.json",
  "test": "ts-node -r tsconfig-paths/register ./spec --ci"
}

4. Run the test and confirm the result.
Alt Text

Debug in VSCode

To debug the backend in VSCode, follow the steps below.

1. Add following json object to launch.json

{
    "type": "node",
    "request": "launch",
    "name": "Debug Backend",
    "runtimeArgs": [
        "-r", "ts-node/register",
        "-r", "tsconfig-paths/register",
    ],
    "args": [
        "${workspaceRoot}/react-backend/src/index.ts",
        "--env=development"
    ],
    "cwd": "${workspaceRoot}/react-backend",
    "protocol": "inspector"
}

2. Place breakpoint anywhere and select the "Debug Backend" profile. Start debugging to see if break point hits.

Summary

In this article, I added express backend server and Redis cache. I update React side in the next article.

Go to next article

Discussion

pic
Editor guide