CS 312 - Software Development

CS 312 - Practical Six

Goals

  • Implement a simple API server
  • Learn how to test a server API

Prerequisites

  1. Click through to the GitHub classroom assignment to create your private repository. Then clone that newly created repository to your local computer as you have done previously.

  2. Install the package dependencies by running npm install.

Overview

We are going to create a series of routes that implement the Film Explorer API.

Our server will use "in memory" data storage. In other words, we will read in the contents of films.json and store it in a JavaScript Map with the id as the key and the film object as the value.

Changes (i.e., ratings), will be made to this local copy of the data providing the appearance of persistence, but if the server is restarted, the server will return to the original copy of the data (also, the dev server rebuilds periodically, and when it does, it purges our changes). For proper persistence, we will require some form of database, which we will discuss in a few weeks.

Loading the data

Since we are sharing the data store across several routes, we will put it in a different file.

Create a new directory called lib in the src directory.

Inside of that directory, create a file called backend-utils.js.

In that file, start by importing fs and path. Note that these two are Node modules, and they are not available in the web browser

import fs from 'fs';
import path from 'path';

Then, create a new Map to store the films:

export const films = new Map();

This will be exported because it is our data source.

Next, we will defined an initialize function that reads in the file and stores the contents in films.

function initialize() {
  const dataDirectory = path.join(process.cwd(), 'data');
  const fullPath = path.join(dataDirectory, 'films.json');
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const data = JSON.parse(fileContents);
  data.forEach((film) => {
    films.set(film.id, film);
  });
}

The steps in here are as follows:

  • build the path to the data directory
  • read in the data from the file
  • parse the data as JSON
  • iterate over the array, loading each film into films, keyed to its id

Finally, call the initialize function.

Get all the films

The first route we will implement will be /api/films, which should return all films.

Recall that we create our API Routes in Next.js by creating files in the the api directory. So, inside of the pages directory, create an api directory.

Create a films directory inside of that.

Create an index.js inside of that.

Type in the following (yes, type, don't paste):

import nc from 'next-connect';

const handler = nc().get((req, res) => {});

export default handler;

This is the general template we will follow for all of our endpoints. We create a handler with next-connect, and then we create endpoints with HTTP verbs.

Returning all of the films is pretty straightforward.

First, we need to import the film collection from our other file:

import { films } from '../../../lib/backend-utils';

The film data is in a Map, so we need to extract the values and convert them to an array first.

Then, we will set the status code on the response and load our data with the json function, which will stringify our data, load it into the body of the response, and set the headers appropriately. (all in one line)

res.status(200).json(Array.from(films.values()));

That's it -- you just wrote your first endpoint.

Check the route

Start up the dev server.

There are a number of ways that we can check that the route works.

On the command line, you can use curl

$ curl localhost:3000/api/films

In the browser, you can visit <localhost:3000/api/films>.

Or you could just visit <localhost:3000>. if the route works, you should see the page populated with films (I updated the routes in the fetch calls for you already).

Serve a single film

While FilmExplorer doesn't actually make use of the GET /api/films/:id route, it is instructive to make it anyway (and it can be useful for testing).

Recall that we can create dynamic routes by using square brackets in the name.

Create a new file called [id].js inside of the films directory.

Following the template of index.js, set up a new endpoint for GET.

For the dynamic routes, we need access to the matched variable at the end. Since we called the file [id].js, the variable name will be id. We can extract it from the query with

const { id } = req.query;

We then get the film from films with films.get(+id). Why +id? The value returned in the query is a string, and the keys to our map are all integers. So we need to convert the value in id to a number.

Use the same pattern from above to set the status code and return the film as json.

Updating the ratings

Our final route will be a PUT request to '/api/films/:id'. This accepts a film with changes (presumably a rating), and uses it to update the local copy.

Add a PUT endpoint after the GET you just completed.

Get the id again.

Since this is a PUT command, the client has sent us the modifications for the film in the body of the request. Because Next.js already applied the body parser middleware, the JavaScript object is parked in req.body:

const updatedFilm = req.body;

Now, you want to merge the new film with the copy we have of the old one and store it back in films

const mergedFilm = { ...films.get(+id), ...updatedFilm };
films.set(mergedFilm.id, mergedFilm);

Finally, send the mergedFilm back in the response.

Testing Your Server

There are several ways to test your server, both "informally" and "formally".

"Informal" Testing with curl or the browser

As we showed above, some quick test can be done using curl or the browser. However, while curl does have the capability of issuing any kind of HTTP request, it quickly ceases to be the quick and easy choice when you are issuing PUT and POST requests.

"Informal" Testing with the Application

An obvious approach is to launch the application and see if all of its functionality is present.

Of course, this only works in some situations (such as this one where you are handed a fully formed client). In general, this can be problematic as you greatly increase the potential source of errors, so when something does go wrong, it will be difficult to track down what it was.

"Formal" Unit/Integration Testing with Jest

For a more formal approach, you can use Jest along with other libraries to test the routes in a similar fashion.

For this project we added a library called SuperTest, which is used for easily testing HTTP APIs.

Writing Tests

If you look in __tests__/routes.test.js, you will find the start of a collection of tests. You will see our familiar testing pattern, in which you define a test suite, use the beforeEach "setup" function to create a consistent test environment (making the tests "Independent" and "Repeatable"), then execute a set of tests. Each of those tests executes some code, i.e. makes a HTTP request to the API, then makes a set of assertions about the response. In the code below there are examples of using Jest for assertions, and also using the features of the SuperTest library for assertions. Because SuperTest is designed for testing APIs it can be more concise.

You will note that the code in beforeEach has some unfamiliar things in it. Because our routes are buried inside of the Next.js server, we need to be a little clever. In essence, we have to fire up a Next server instance. This uses a bunch of code (in test-utils) that was lifted from the guts of the Next code that they use for internal testing. For the most part you can just use it and not worry about it too much.

Add the final test to check if the parameterized GET route functions properly. You can run the server tests with npm test.

Finishing Up

  1. Add and commit your changes to Github. Make sure to add and commit the new files you created.
  2. Submit your repository to Gradescope

I will expect that:

  • Unparameterized GET request works
  • Parameterized GET request works
  • PUT request works
  • Test for parameterized GET
  • Passes all ESLint checks