Birdspotter Service

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:

Note 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.