Most popular API design pattern; definition rather blurry. The REST (Representational State Transfer) pattern defines:
- Database connections
- Route paths
- HTTP verbs that let the users perform actions
REST works best with basic, relational and flat data models, but doesn't lend itself well to complex structures with lots of nodes etc.
Node.js is asyncronous and event driven, and thus works well with high concurrency API that are not CPU intensive.
Express is Node.js' almost standard tool for building APIs. It handles all of the tedious tasks, such as managing sockets, matching routes, asynchronous operations and error handling. Error handling is especially important because Node.js is single-threaded.
Non-relational document store that is easy to get started with and scales well.
Simple way to test API endpoints and routes, especially POST requests is to use HTTPie:
// Get everything
http GET localhost:3000/api/item
// Get by id
http GET localhost:3000/api/item/item-id
// Update
http PUT localhost:3000/api/item/item-id
// Add new
http POST localhost:3000/api/item body:='{ "new-item": "new-item-data" }'
// Delete
http DELETE localhost:3000/api/item/item-id
Middleware, in Express and other server contexts, is a list of functions that execute in order before your controllers. Controller means the callback function below. They sit between your request and your response.
app.get('/', (req, res) => {
res.send({ message: 'Hello' })
})
Middleware lets you execute functions on an incoming request with guaranteed order. Middleware can be used for authenticating, transforming the requests, tracking and error handling. The order in which you register your middleware is the order they will be executed in.
// Examples of middleware
app.use(cors());
app.use(json());
app.use(urlencoded({ extended: true }))
app.use(morgan('dev'))
Middleware can also respond to a request like a controller, but that is not their intended purpose.
Since middleware is essentially a function that runs at a certain time, creating custom middleware is simple. First, we need to define a function:
const log = (req, res, next) => {
console.log("Logging things");
next();
}
Here, it is worth noting that we added a new parameter to the function, next
. This function is Express' way of tying up the middleware. Our function doesn't know it's place in the middleware order, so by calling the next()
function, it can tell Express that it is all done, and that Express can move on to the middleware that's next in line. To take our custom middleware into use on the app level, we can simply use app.use()
:
app.use(log)
Or, if we would prefer to use it in a single route, we can define it in that route:
app.get('/', log, (req, res) => {
res.send({ message: 'Hello' })
})
If we needed to add multiple middleware to a single route, we can use an array to register them in the required order:
app.get('/', [log, log2, log3], (req, res) => {
res.send({ message: 'Hello' })
})
Express has been designed with REST in mind, and has all the features you need to match routes and verbs.
- Express' route matching system allows you to use exact, regex, glob and parameter matching
- Express also supports HTTP verbs on a route based level
// Regex that matches all routes that start with 'me'
app.get('^(/me*)', (req, res) => {
res.send({ message: 'Hello from me route' })
})
// Glob that matches all routes that start with 'user' and have something after that
app.get('/user/*', (req, res) => {
res.send({ message: 'Hello from user route' })
})
//
app.post(...) // Create
app.get(...) // Read
app.put(...) // Update
app.delete(...) // Delete
When creating routes, note that the order in which you define the routes matters. If you create two identical routes, Express will use the first matching route that it finds, and executes that. The second one will not be used.
For abstraction, Express lets you create sub routers that combine to make a full router. Subrouters can have their own middleware or inherit from the parent router. Subrouter cannot listen to ports, but like the app created with express()
, routers can define verbs and route paths. After you have defined a router, you must register, or mount the router with the base App.
// Create a new router.
const router = express.Router()
// Define a route in the router
router.get('/you', (req, res) => {
res.send({ you: 'Jane ' })
})
// Mount the router to the main app
app.use('/api', router)
Using the definitions above, any calls to http://hostname:3000/api
would be delegated to the router we created. In other words, the GET
route that we defined would be accessible through http://hostname:3000/api/you
.
If you need to define middleware for the router, you can use the same pattern as with the base app, for example, router.use(json());
.
Writing lots of routes can get frustrating especially if you have multiple similar routes where the only difference is the verb thats being used. Express lets us use verb methods to clean up writing these:
app.route('/cat')
.get()
.post()
app.route(/cat/:id)
.get()
.put()
.delete()
MongoDB is a schemaless DB, so how to create a schema for it. Schemas help you validate your data before it goes into the database, which helps you avoid all sorts of nonsense, like validating data on the frontend. MongoDB has added support for creating schemas, but Mongoose is an app that will make schema creation much easier for you. Mongoose lets you create a schema that maps to a MongoDB collection and it defines the shape of each document within that collection. A schema holds the instructions for models that are created based on the schema; therein, you define the keys, validations, names, indexes and hooks (or Middleware. Essentially, functions that are run during the execution of asynchronous functions).
const userSchema = new mongoose.Schema(
{
firstName: String,
lastName: {
type: String,
required: true
},
nationality: {
type: String,
default: 'Finnish'
},
email: {
type: String,
required: true,
unique: true,
trim: true
},
// You can also define references to other collections in Mongoose schemas.
// Here, we're saying that we want to look in the "user" collection and look
// for a user with a certain ID. The "user" here is a reference to the name of
// the Mongoose Model. An ObjectID is how MongoDb does IDs; uniquely generated
// ID strings represented as objects.
createdBy: {
type: mongoose.SchemaTypes.ObjectId,
required: true,
ref: 'user'
},
},
{ timestamps: true}
);
// If we were to link users to a list model, we could define the list so that it only
// accepts a single instance of a firstName and lastName pair by defining a Compound Index
userSchema.index({ list: 1, firstName: 1, lastName: 1 }, { unique: true })
You can then use the schema to create a Model which you can then use to create documents into and read documents from and underlying MongoDB database.
In practice, you must create schemas/models for each REST resource you want to expose through an API.
Controllers are essentially middleware, but with the intent of returning some data based on a request. Controllers' task is to handle what data combination of a route and a verb can acces from a database or another data source. You can think of controller as the final middleware in the stack for a request. There is no intent to proceed to another middleware function after the controller (the only case might be in the case of an error handler).
router.route('/')
.get((req, res) = {
res.end() // End the response; no message sent back
res.status(404) // Set the status code of the response
res.status(404).end() // Status is chainable; the 404 can be sent out like this.
res.status(404).send({ msg: 'Not found' }) // ...Or by combining it with a message.
res.json({ message: 'Hello' }) // Send tries to figure out the type you'se sending,
// but you can be explicit about it, too
})
Controllers implement the logic that interacts with database models created with Mongoose. You can generalize controllers to work with many different models if you use a REST approach that requires CRUD actions on resources. If you have models that are identical in the sense of which methods you can use on them, you could create a single, generalized controller to handle all of these models, and pass in the model in use to the controller.
Mongoose models map nicely with CRUD:
- C - model.create(), new model()
- R - model.find(), model.findOne(), model.findById()
- U - model.update(), model.findByIdAndUpdate(), model.findOneAndUpdate()
- D - model.remove(), model.findByIdAndRemove(), model.findOneAndRemove()
import { Item } from './item.model'
import mongoose from 'mongoose'
const run = async () => {
// Connect to database first.
const item = await Item.create({
name: 'Clean up',
createdBy: mongoose.Types.ObjectId(), // Creates a fake id
list: mongoose.Types.ObjectId()
})
console.log(item)
}
run()
When reading a document, you generally use one of the find functions to locate the document from the database. The exec()
at the end turns the operation into a real promise and executes it.
console.log(await Item.findById(item._id).exec())
For updating a document, use one of the update functions listed above. A special thing to note about updating a document is that the response in REST is waiting back the updated object but Mongoose does not send one back by default. You must do this by using the { new: true }
parameter when calling update.
const updated = await Item.findByIdAndUpdate(item._id, { name: 'eat' }, { new: true }).exec()
When deleting a document, use one of the delete functions listed above.
const updated = await Item.findByIdAndDelete(item._id, { new: true }).exec()
After we have defined routes and models, we need to hook the routes to the models to be able to perform CRUD operations on the models based on route+verb combinations. To do that, we need to utilize controllers. Making generalized controllers is fairly straightforward:
First, you need to define some controller functions:
export const getOne = model => async (req, res) => {
const id = req.params.id
const userId = req.user._id
const item = await model.findOne({ _id: id, createdBy: userId }).exec()
if (!item) {
return res.status(400).end()
}
res.status(200)
res.json({ data: item })
}
export const getMany = model => async (req, res) => {
const user = req.user._id
const items = await model.find({ createdBy: user }).exec()
res.status(200)
res.json({ data: items })
}
Next, to make these available to the general public, you need to export them:
export const crudControllers = model => ({
removeOne: removeOne(model),
updateOne: updateOne(model),
...
})
The crudControllers
function, given a model, returns an object that references the functions defined above and passes the model into them. Finally, we need to import and use this:
import { crudControllers } from '../../utils/crud'
import { Item } from './item.model'
export default crudControllers(Item)
If necessary, we can override certain controllers using spread:
export default {
...crudControllers(Item),
getOne() {
...
}
}
You can never protect your API 100%, but by requiring authentication, you can make your API somewhat safer. Authentication, in this case means controlling if an incoming request can proceed or not. Authorization, on the other hand, means controlling if an authenticated request has the correct permissions to access the resource it is trying to access. Identification is determining who the requestor is.
One way to handle API authentication is through Json Web Tokens (JWTs) in a stateless manner. This manner of authentication is called a bearer token strategy. JWTs are created by a combination of secrets on the API and a payload, such as a user object (with a certain role, for instance).
The JWT must be sent with every request, and the API will then try and verify if the token was created with the secrets the API expects it to be created with. After successful verification, the JWT payload is accessible to the server, and it can be used for authorization and indentification.
Jest runs tests in paraller, which makes working with Jest and MongoDb slightly complicated. One way around this is to write a script that creates a new database for each describe
block and removes it after the test is run. This way, we can avoid running stateful tests that know about the tests that ran before it. You can set up this test in package.json along with some other properties:
"jest": {
"verbose": true,
"testUrl: "http://localhost",
"testEnvironment": "node",
"setupTestFrameworkScriptFile": "<rootDir>/test-db-setup.js"
"testPathIgnorePatterns: [
"dist/"
],
"restoreMocks": true
}