API that implements the basic features of stack overflow
PROJECT FEATURE | STATUS |
---|---|
User Sign up | ✅ |
User Sign in | ✅ |
Ask Questions | ✅ |
View Questions | ✅ |
Upvote or Downvotes questions | ✅ |
Answer Questions | ✅ |
Upvote or Downvotes answers | ✅ |
Subscribe to Questions | ✅ |
Test Driven Development | ✅ |
Continuous Integration and Continuous Deployment | ✅ |
Search (Elastic Search) | ✅ |
Micro service Architecture | ✅ |
Background Services in RabbitMQ | ✅ |
Automated Pull Request Validation based on ESLint | ✅ |
Test Coverage Reporting | ✅ |
- 👮 Authentication via JWT
- Routes mapping via express-router
- Documented using Swagger. Find link to docs here
- Background operations are run on stack-overflow-lite-background-service. This is private repo and will return 404 if you do not have access
- Uses MongoDB as database.
- Mongoose as object document model
- Environments for
development
,testing
, andproduction
- Linting via eslint
- Integration tests running with Jest
- Built with npm scripts
- Asynchronous and background operations via RabbitMQ. You can find the RabbitMQ Management Here. Please contact the Developer for login credentials
- Uses Elastic Search for search operations
- Jenkins for continuous integration and continuous deployment. Find the CI-Server Here. Please contact the Developer for login credentials
- Digital Ocean for deployment. Please find the link to the health check Here and API documentation here
- Containerization with Docker
- Pull request style enforcement using HoundCI
- example for User model and User controller, with jwt authentication, simply type
npm i
andnpm start
- Install & Use
- Folder Structure
- Repositories
- Controllers
- Models
- Middlewares
- Services
- Config
- Routes
- Test
- npm Scripts
Start by cloning this repository
# HTTPS
$ git clone https://github.com/mogbeyi-david/softcom-takehome-test.git
then
# cd into project root
$ npm install
$ npm start
This codebase has the following directories:
- api - for controllers and routes.
- config - Settings for any external services or resources.
- helper - Contains functions to support the controllers
- logs - Output of API logs are found here
- middlewares - All middleware functions for authentication, authorization etc
- models - Database schema definitions, plugins and model creation
- repositories - Wrappers for database functions (Similar to DAO)
- scripts - Executable files specifying automated deployment process
- services - Wrapper classes and methods for external services
- tests - Automated tests for the project
- Utility - Functions used often in codebase and tests
- validations Request payload validation at API level
Repositories are wrappers around the models and use dependency injection to take the model as input I used Mongoose as ODM, if you want further information read the Docs. Example Controller for all CRUD operations:
const User = require('../models/User');
class UserRepository
{
/**
*
* @param user
*/
constructor(user)
{
this.user = user;
}
/**
*
* @param user
* @returns {Promise<void>}
*/
async create(user)
{
return await this.user.create(user);
}
/**
*
* @param email
* @returns {Bluebird<TInstance | T>}
*/
async findByEmail(email)
{
return await this.user.findOne({email});
}
/**
*
* @returns {Promise<*>}
*/
async findAll()
{
return await this.user.find({}, {password: false, isAdmin: false}).lean();
}
/**
*
* @param id
* @returns {Promise<*|TInstance|T>}
*/
async findOne(id)
{
return await this.user.findOne({_id: id});
}
/**
*
* @param user
* @param id
* @returns {Promise<void>}
*/
async update(user, id)
{
return await this.user.findOneAndUpdate({_id: id}, user, {new: true});
}
/**
*
* @param query
* @returns {Promise<void|Promise>}
*/
async search(query)
{
return this.user.esSearch({
query_string: {
query,
},
});
}
}
module.exports = new UserRepository(User);
Controllers in the codebase have a naming convention: ModelnameController.js
and uses an ES6 class pattern.
To use a model functions inside the controller, require the repository in the controller and use it. The controller should not have direct access to the Model except through the repository
Example Controller for all CRUD operations:
const status = require("http-status");
const _ = require("lodash");
const mongoose = require("mongoose");
const UserRepository = require("../../../repositories/UserRepository");
const validateCreateUser = require("../../../validations/user/validate-create-user");
const validateUpdateUser = require("../../../validations/user/validate-update-user");
const response = require("../../../utility/response");
const hasher = require("../../../utility/hasher");
const handleCall = require("../../../helper/handleCall");
class UserController {
/**
* @Author David Mogbeyi
* @Responsibility: Creates a new user
*
* @param req
* @param res
* @param next
* @returns {Promise<*>}
*/
async create (req, res, next) {
const { error } = validateCreateUser(req.body); // Check if the request payload meets specifications
if (error) {
return response.sendError({ res, message: error.details[0].message });
}
let { firstname, lastname, email, password } = req.body;
return handleCall((async () => {
const existingUser = await UserRepository.findByEmail(email);
if (existingUser) {
return response.sendError({ res, message: "User already exists" });
}
password = await hasher.encryptPassword(password);
const result = await UserRepository.create({ firstname, lastname, email, password });
const user = _.pick(result, ["_id", "firstname", "lastname", "email"]);
return response.sendSuccess(
{ res, message: "User created successfully", body: user, statusCode: status.CREATED });
}), next);
}
/**
*
* @param req
* @param res
* @param next
* @returns {Promise<*>}
*/
async getAll (req, res, next) {
return handleCall((async () => {
const users = await UserRepository.findAll();
return response.sendSuccess({ res, body: users, message: "All users" });
}), next);
}
/**
*
* @param req
* @param res
* @param next
* @returns {Promise<*>}
*/
async getOne (req, res, next) {
return handleCall((async () => {
let { id } = req.params;
id = mongoose.Types.ObjectId(id);
const result = await UserRepository.findOne(id);
if (!result) {
return response.sendError({ res, message: "User not found", statusCode: status.NOT_FOUND });
}
// Send only in-sensitive data back to the client.
const user = _.pick(result, ["_id", "firstname", "lastname", "email"]);
return response.sendSuccess({ res, body: user, message: "Single user gotten successfully" });
}), next);
}
/**
*
* @param req
* @param res
* @param next
* @returns {Promise<*>}
*/
async update (req, res, next) {
return handleCall((async () => {
const { error } = validateUpdateUser(req.body);
if (error) return response.sendError({ res, message: error.details[0].message });
let { id } = req.params;
let { firstname, lastname, email, password } = req.body;
const existingUser = await UserRepository.findOne(id);
if (!existingUser) {
return response.sendError({
res,
message: "User does not exist",
statusCode: status.NOT_FOUND,
});
}
const isValidPassword = await hasher.comparePasswords(password, existingUser.password);
if (!isValidPassword) {
return response.sendError({ res, message: "Wrong Password" });
}
let user = { firstname, lastname, email };
const result = await UserRepository.update(user, id);
user = _.pick(result, ["firstname", "lastname", "email"]);
return response.sendSuccess({ res, message: "User details updated successfully", body: user });
}), next);
}
}
module.exports = new UserController;
Models in this boilerplate have a naming convention: Model.js
and uses Mongoose to define our Models, if you want further information read the Docs.
Example User Model:
require("dotenv").config();
const mongoose = require("mongoose");
const mexp = require("mongoose-elasticsearch-xp").v7;
const jwt = require("jsonwebtoken");
const Schema = mongoose.Schema;
const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY;
const UserSchema = new Schema({
firstname: {
type: String,
required: true,
es_indexed: true
},
lastname: {
type: String,
required: true,
es_indexed: true
},
email: {
type: String,
required: true,
unique: true,
es_indexed: true
},
password: {
type: String,
required: true,
minlength: 6
},
isAdmin: {
type: Boolean,
default: false
},
}, {timestamps: true});
UserSchema.methods.generateJsonWebToken = function () {
return jwt.sign({
userId: this._id,
firstname: this.firstname,
lastname: this.lastname,
email: this.email,
isAdmin: this.isAdmin
}, JWT_SECRET_KEY);
};
UserSchema.plugin(mexp);
// Creates the user model
const User = mongoose.model("User", UserSchema);
module.exports = User;
Middleware are functions that can run before hitting a route.
Example middleware:
Only allow if the user is logged in
Note: this is not a secure example, only for presentation purposes
require("dotenv").config();
const status = require("http-status");
const jwt = require("jsonwebtoken");
const response = require("../utility/response");
const auth = (req, res, next) => {
const token = req.header("x-auth-token");
if (!token) {
return response.sendError({
res,
statusCode: status.UNAUTHORIZED,
message: "You need to be signed in to perform this operation"
});
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET_KEY);
next();
} catch (exception) {
next(exception);
}
};
module.exports = auth;
To use this policy on all routes that only admins are allowed:
api.js
const SubscriptionController = require("../controllers/SubscriptionController");
const authMiddleware = require("../../../middlewares/auth");
router.post("/question", [authMiddleware], SubscriptionController.subscribeToQuestion);
Or to use several middlewares for one route
api.js
const SubscriptionController = require("../controllers/SubscriptionController");
const authMiddleware = require("../../../middlewares/auth");
const adminMiddleware = require("../../../middlewares/admin");
router.post("/question", [authMiddleware], SubscriptionController.subscribeToQuestion);
router.get("/question/:id", [authMiddleware, adminMiddleware], SubscriptionController.getAllForQuestion);
module.exports = router;
The auth.middleware
checks whether a JSON Web Token
(further information) is send in the header of an request as x-auth-token
.
The middleware runs default on all api routes as specified in the route.
The admin.middleware
checks whether a the decoded version of the JSON Web Token
(further information) has the isAdmin property set to 1
.
The middleware runs default on all api routes as specified in the route.
Services are wrapper classes around external tools like RabbitMQ used in the project
Example service:
Publish data to RabbitMQ
const connectionString = require("../config/rabbitmq/connection");
const open = require("amqplib").connect(connectionString);
class RabbitMqService {
/**
*
* @param queue
* @param data
*/
static publish(queue, data) {
open.then(function (conn) {
return conn.createChannel();
}).then(function (ch) {
//eslint-disable-next-line
return ch.assertQueue(queue).then(function (ok) {
return ch.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
});
}).catch(console.warn);
}
}
module.exports = RabbitMqService;
Holds all the server and service configurations.
Note: if you use MongoDB make sure mongodb server is running on the machine This two files are the way to establish a connection to a database. Now simple configure the keys with your credentials from environment variables
require("dotenv").config();
const environment = process.env.NODE_ENV || "development";
let connectionString;
switch (environment) {
case "production":
connectionString = "";
break;
case "testing":
connectionString = `mongodb://${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.TEST_DB_NAME}`;
break;
default:
connectionString = `mongodb://${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
}
module.exports = connectionString;
To not configure the production code.
To start the DB, add the credentials for production. add environment variables
by typing e.g. export DB_USER=yourusername
before starting the api or just include credentials in the env file
Here you define all your routes for your api.
For further information read the guide of express router.
Example for User Resource:
Note: Only supported Methods are POST, GET, PUT, and DELETE.
userRoutes.js
const express = require('express');
const router = express.Router();
const UserController = require('../controllers/UserController');
const AuthController = require('../controllers/AuthController');
const authMiddleware = require('../../../middlewares/auth');
const validateObjectIdMiddleware = require(
'../../../middlewares/validate-objectId');
router.post('/forgot-password', AuthController.sendResetPasswordLink);
router.post('/reset-password', AuthController.resetPassword);
router.post('/login', AuthController.login);
router.post('/', UserController.create);
router.get('/', UserController.getAll);
router.get('/:id', [validateObjectIdMiddleware], UserController.getOne);
router.put('/:id', [validateObjectIdMiddleware], UserController.update);
module.exports = router;
To use these routes in your application, require the router in the routes/index.js file, give it an alias and export it to app.js
const userRouter = require("./user");
const questionRouter = require("./question");
const answerRouter = require("./answer");
const subscriptionRouter = require("./subscription");
const searchRouter = require("./search");
module.exports = {
userRouter,
questionRouter,
answerRouter,
subscriptionRouter,
searchRouter
};
app.js
const {userRouter: userRouterV1} = require("./api/v1/routes");
const {questionRouter: questionRouterV1} = require("./api/v1/routes");
const {answerRouter: answerRouterV1} = require("./api/v1/routes");
const {subscriptionRouter: subscriptionRouterV1} = require("./api/v1/routes");
const {searchRouter: searchRouterV1} = require("./api/v1/routes");
All test for this boilerplate uses Jest and supertest for integration testing. So please read their docs on further information.
Note: those request are asynchronous, we use
async await
syntax.
Note: As we don't use import statements inside the api we also use the require syntax for tests
All controller actions are wrapped in a function to avoid repetitive try...catch syntax
To test a Controller we create requests
to our api routes.
Example GET /user
from last example with prefix prefix
:
const request = require('supertest');
const {
beforeAction,
afterAction,
} = require('../setup/_setup');
let api;
beforeAll(async () => {
api = await beforeAction();
});
afterAll(() => {
afterAction();
});
test('test', async () => {
const token = 'this-should-be-a-valid-token';
const response = await request(server)
.put(`${baseURL}/${testQuestion._id}`)
.set("x-auth-token", token)
.send(payload);
expect(response.status).toEqual(401);
});
Are usually automatically tested in the integration tests as the Controller uses the Models, but you can test them seperatly.
There are no automation tool or task runner like grunt or gulp used for this project. This project only uses npm scripts for automation.
This is the entry for a developer. This command:
- runs nodemon watch task for the all files connected to the codebase
- sets the environment variable
NODE_ENV
todevelopment
- opens the db connection for
development
- starts the server on
localhost
This command:
- sets the environment variable
NODE_ENV
totesting
- creates the
test database
- runs
jest --coverage --runInBand
for testing with Jest and the coverage - drops the
test database
after the test
This command:
- sets the environment variable to
production
- opens the db connection for
production
- starts the server on 127.0.0.1 or on 127.0.0.1:PORT_ENV
Before running on any environment you have to set the environment variables:
NODE_ENV=
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_PORT=
TEST_DB_NAME=
DB_FOR_LOGS=
PORT=
JWT_SECRET_KEY=
APP_URL=
MAILTRAP_HOST=
MAILTRAP_PORT=
MAILTRAP_USERNAME=
MAILTRAP_PASSWORD=
APP_EMAIL=
APP_EMAIL_PASSWORD=
EMAIL_HOST=
RABBITMQ_USERNAME=
RABBITMQ_PASSWORD=
RABBITMQ_HOST=
RABBITMQ_PORT=
ELASTIC_SEARCH_PORT=
RESET_PASSWORD_QUEUE="
MIT © Stack Overflow Lite Api