So why do we need to think of architecture when it comes to building APIs. After all, we can knock together a simple API within a few minutes:
Let's use Express and PostgreSQL to quickly put together an API to CRUD customers to a database
{
"name": "easyapi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.1",
"pg": "^8.7.3",
"sequelize": "^6.20.1"
}
}
//customer-model.js
const { Sequelize, Model, DataTypes } = require('sequelize');
const sequelize = new Sequelize("postgres://postgres@localhost:5432/customerdb");
const Customer = sequelize.define('customer', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
name: DataTypes.STRING,
}, { timestamps: false });
module.exports = Customer
//customer-router.js
const express = require("express")
const router = express.Router()
const Customer = require("./customer-model")
router.get("/", async (req, res, next) => {
try {
const result = await Customer.findAll({ where: {} })
res.status(200).json(result)
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.post("/", async (req, res) => {
try {
const item = await Customer.findOne({ where: { name: req.body.name } })
if (item) {
throw new Error("Customer Already Exists")
} else {
const result = await Customer.create(req.body)
res.status(201).json(result)
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.get("/:id", async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.findByPk(req.params.id)
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.delete("/:id", async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.destroy({ where: { id: req.params.id } })
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.put("/:id", async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.update(req.body, {
where: {
id:
req.params.id
}
})
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.use("/*", (req, res) => {
res.status(404).json({ message: "This route does not exist" })
})
module.exports = router
//server.js
const express = require("express")
const server = express();
server.use(express.json());
server.get("/", (req, res) => {
res.send("Welcome to my API")
})
server.use("/customer", require("./customer-router"));
server.listen(8080, () => console.info("Server Running..."))
Easy as that!
It might not look that way but there is actually quite a lot happening here. If we intend building scalable and flexible applications we need a way of breaking up the complexity into small and testable parts.
Complexity! What complexity?
Well, a few things are noticed:
- No input validation
- All errors are caught and returned the same way with no mapping of error to predictable status codes and messages
- All functionality is kept in the route handler.
- No tests
- Every route handler will need to be touched to change the data source from Postgres to let's say MongoDB
Ok let's look at how can fix input validation first
Validation is a big topic to discuss and we won't be doing that here, however we should look for a good validation library to do the heavy lifting for use.
A good, mature and well starred module is joi. Check it out. Great documentation here
Joi works by validating data based on schemas. for example:
const Joi = require('joi');
const schema = Joi.object().keys({
name: Joi.string().alphanum().min(3).max(30).required(),
birthyear: Joi.number().integer().min(1970).max(2013),
});
const dataToValidate = {
name: 'chris',
birthyear: 1971
}
const result = schema.validate(dataToValidate);
// result.error == null means valid
To validation our body, params and query of the request there is a middleware function we'd need to write
function validate({ body, params, query }) {
return function (req, res, next) {
let errors = []
if (body) {
const { error } = body.validate(req.body, { abortEarly: false })
if (error) {
errors.push({ message: error.message, type: "body" })
}
}
if (params) {
const { error } = params.validate(req.params, { abortEarly: false })
if (error) {
errors.push({ message: error.message, type: "params" })
}
}
if (query) {
const { error } = query.validate(req.query, { abortEarly: false })
if (error) {
errors.push({ message: error.message, type: "query" })
}
}
if (errors.length > 0) {
return res.status(400).json(errors)
}
next()
}
}
This validation middleware examines parts of the request input(body, params or query) agains a validation schema. It gathers all validations error into an array called errors and if any errors are found, the server responsed with a 400 status code with descriptive error messages
Let's update our router with this middleware to illustrate
//customer-router.js
const express = require("express")
const router = express.Router()
const Customer = require("./customer-model")
const Joi = require('joi');
const validate = require("./middleware/validate")
router.get("/", async (req, res, next) => {
try {
const result = await Customer.findAll({ where: {} })
res.status(200).json(result)
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.post("/",
validate({
body: Joi.object({
name: Joi.string().required()
})
}),
async (req, res) => {
try {
const item = await Customer.findOne({ where: { name: req.body.name } })
if (item) {
throw new Error("Customer Already Exists")
} else {
const result = await Customer.create(req.body)
res.status(201).json(result)
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.get("/:id",
validate({
params: Joi.object({
id: Joi.number().integer().required()
})
}),
async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.findByPk(req.params.id)
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.delete("/:id",
validate({
params: Joi.object({
id: Joi.number().integer().required()
})
}),
async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.destroy({ where: { id: req.params.id } })
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.put("/:id",
validate({
params: Joi.object({
id: Joi.number().integer().required()
}),
body: Joi.object({
name: Joi.string()
})
}),
async (req, res) => {
try {
const item = await Customer.findByPk(req.params.id)
if (item) {
const result = await Customer.update(req.body, {
where: {
id:
req.params.id
}
})
res.status(200).json(result)
} else {
throw new Error("Customer not found")
}
} catch ({ message }) {
res.status(500).json({ message })
}
})
router.use("/*", (req, res) => {
res.status(404).json({ message: "This route does not exist" })
})
module.exports = router
We can clean this up nicely with express-joi-validation module; a middleware for validating express inputs. This will intercept the request and validate the inputs as we see fit.
...
const Joi = require("joi")
const {createValidator} = require('express-joi-validation')
const validator = createValidator()
...
router.put("/:id",
validator.params(Joi.object({
id: Joi.number().required(),
})),
validator.body(Joi.object({
name: Joi.string(),
})),
async (req, res) => {
await Customer.update(req.body, {
where: {
id:
req.params.id
}
})
res.status(200).json({ message: "Updated" })
})
Let's look at the customer router now
//customer-router.js
const express = require("express")
const Joi = require("joi")
const router = express.Router()
const { createValidator } = require('express-joi-validation')
const validator = createValidator()
const Customer = require("./customer-model")
const paramsValidate = validator.params(Joi.object({
id: Joi.number().required(),
}))
const bodyValidate = validator.body(Joi.object({
name: Joi.string(),
}))
router.get("/", async (req, res) => {
const result = await Customer.findAll({ where: {} })
res.status(200).json(result)
})
router.post("/", bodyValidate, async (req, res) => {
const result = await Customer.create(req.body)
res.status(201).json(result)
})
router.get("/:id", paramsValidate, async (req, res) => {
const result = await Customer.findByPk(req.params.id)
res.status(200).json(result)
})
router.delete("/:id", paramsValidate, async (req, res) => {
const result = await Customer.destroy({ where: { id: req.params.id } })
res.status(200).json(result)
})
router.put("/:id", paramsValidate, bodyValidate, async (req, res) => {
await Customer.update(req.body, {
where: {
id:
req.params.id
}
})
res.status(200).json({ message: "Updated" })
})
module.exports = router
We need a consistent way of handling expected or unexpected errors in our router handlers