Suppose you are organizing a bird counting event. The volunteers will station themselves at locations in a park and identify all the birds that they see using a mobile device. You want to provide a web service that tracks counters for all the different species, and you begin with this Express service:
const express = require('express');
const speciesCounts = {};
const service = express();
// TODO: Add endpoints.
const port = 5000;
service.listen(port, () => {
console.log(`We're live on port ${port}!`);
});
You decide to support the following endpoints:
GET /species
, which yields the counts of all speciesGET /species/:name
, which yields the count of a particular speciesPOST /species/:name
, which adds a new species and starts its count at 1PATCH /species/:name
, which increments the count of a speciesDELETE /species/:name
, which deletes a speciesPATCH /species
, which sets multiple species countsNote that similar URLs are used for all endpoints. They are distinguished by parameters and their HTTP methods.
The get-all endpoint is satisfied by sending back the complete speciesCounts
object:
service.get('/species', (request, response) => {
response.json({
ok: true,
results: speciesCounts,
});
});
The get-one endpoint expects the species name as a parameter. The species might not exist in the speciesCount
object. If it doesn't you could return a 404. However, you decide to just return a count of 0 instead. You employ the nullish coalescing operator to look up the real count but fall back to 0 if the lookup yields undefined
:
service.get('/species/:name', (request, response) => {
response.json({
ok: true,
results: {
species: request.params.name,
count: speciesCounts[request.params.name] ?? 0,
},
});
});
The new-species endpoint also expects the species name as a parameter. If the species already exists, you give back a response with an error message and its status code set to 400. Otherwise, you add a new entry to speciesCount
and give back a record of the added species:
service.post('/species/:name', (request, response) => {
const name = request.params.name;
if (speciesCounts.hasOwnProperty(name)) {
response.status(400);
response.json({
ok: false,
results: `Species already exists: ${name}`,
});
} else {
speciesCounts[name] = 1;
response.json({
ok: true,
results: {
species: name,
count: speciesCounts[name],
},
});
}
});
Note the use of the post
method to designate that this endpoint only receives POST
requests.
If any of your spotters has already seen this species, then it will already have a count and you just want to increment its value. The client sends a PATCH
request to edit an existing entry. If the named species is valid, its count is increment and a record of the species is sent back to the client. If the named species is not valid, a 404 error is sent back:
service.patch('/species/:name', (request, response) => {
const name = request.params.name;
if (speciesCounts.hasOwnProperty(name)) {
speciesCounts[name] += 1;
response.json({
ok: true,
results: {
species: name,
count: speciesCounts[name],
},
});
} else {
response.status(404);
response.json({
ok: false,
results: `No such species: ${name}`,
});
}
});
Your delete endpoint removes the named species from speciesCount
. However, if the named species doesn't exist, you give back a 404:
service.delete('/species/:name', (request, response) => {
const name = request.params.name;
if (speciesCounts.hasOwnProperty(name)) {
delete speciesCounts[name];
response.json({
ok: true,
});
} else {
response.status(404);
response.json({
ok: false,
results: `No such species: ${name}`,
});
}
});
In all of the endpoints above, the only data that the client needs to send is the species name. This data is short and can be passed as a URL parameter. The patch-all endpoint, however, might be given an entire database of names and counts. Large data should not be sent in the URL. Rather, it should be sent in the body of the request. Suppose you expect clients to upload a JSON object in the body. You write this endpoint:
service.patch('/species', (request, response) => {
Object.assign(speciesCounts, request.body);
response.json({
ok: true,
});
});
This endpoint by itself doesn't quite work. If you were to log request.body
, you'd find that it was undefined
. That's because the body may be encoded in various ways, and Express doesn't automatically decode it. You turn on the decoding by adding this statement before defining any of your endpoints:
service.use(express.json());
// Endpoints are defined after service.use.
// service.get(...) {
The use
function adds middleware to the request-processing pipeline. When a request arrives, it runs through a gauntlet of middleware before being handed off to the appropriate endpoint callback. This particular middleware might look something like this:
function decodeJsonBody(request, response, next) {
if (request.hasJsonBody) {
request.body = JSON.parse(request.bodyString);
}
next();
}
The next
function advances the request to the next stage of the pipeline. Middleware is useful for factoring out code that should be applied to all endpoints.
That rounds out your web service. There's just one last thing you should be concerned about. What if multiple clients issues requests at the same time? If two PATCH
requests come in at the same time, might one of the increments be lost? If a DELETE
and a PATCH
come in at the same time, might you end up incrementing an undefined
count?
The good news is that race conditions are not possible for this simple service. The event system of Node.js processes only a single request at a time, so each callback will run to completion before another starts executing. However, if you call an asynchronous function, then the callback will complete before a response is sent, and another request may be processed before the first finishes. This interleaving may lead to a race condition.