CS 312 - Practical Seven
Due: 2019-10-18 5p
Goals
- Implement a simple Node+express server
- Practice combining create-react-app (CRA) with server-side code
- Learn how to test a server API
Prerequisites
- 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.
-
Install the package dependencies by running
npm install
. Note that there are threepackage.json
files in the skeleton. One in the root (or "top-level") directory, as well as one in the client directory and one in the server directory. You should runnpm install
in all three locations. You can do so by changing directories or by using the "prefix" option fornpm
, e.g. from the root directorynpm install npm install --prefix client npm install --prefix server
We will do most of our work inside of the
server
directory, so when we add files or install new packages, make sure you do it in this directory (or with that prefix) unless otherwise noted.Windows Users: There appears to be an error using the
prefix
argument on Windows. Instead of usingprefix
you will need to manually change to the client and server directories.
Overview
We are going to use express
to create a series of routes that implement the Film Explorer API.
Note that we have moved the films.json
file into the server. Our server will use "in memory" data storage. In other words, when the server is started, it will read in the contents of films.json
, and store it in a 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. For proper persistence, we will require some form of database, which we will discuss in a few weeks.
Serving films with Express
To make testing and development easier, we will create our routes in a dedicated file. We have called this file routes.js
. This file has a basic skelton in place. The express
module has been imported with const express = require('express');
and an express
instance called app
has been created. You will also see that we have a Map
object called films
, which will be our in memory data store. Finally, at the bottom of the file, we are exporting both app
and films
so that they can be used in other files.
Now we can create the route that fetches all of the films (put this before the exports):
app.get('/api/films', (request, response) => {
response.send(Array.from(films.values()));
});
Why the different module syntax? Node's module support, based on the CommonJS standard (CJS), preceded the module syntax in ES6. ES6 module syntax (ESM) is coming to Node (but is only partially implemented). We can use the Babel transpiler to convert ESM to CJS, but that is one more piece of infrastructure we need to setup. For simplicity, we will stick to require
and module.exports
for Node-based servers.
Loading the film collection
Reading in the collection of films will take a moment, and we don't want to start the server until the data is prepared. The main server code will be placed in index.js
. If you open that file, you will see that we have imported app
and films
, and provided the code to start a server directly from the express
instance (note that this has the same effect as the approach in the notes that used the http
module explicitly).
To read in films.json
, we will use the fs
(file system), path
, and util
modules, so add these imports as well:
const fs = require('fs');
const path = require('path');
const util = require('util');
To read in the file, we will use the fs.readFile
function. If you read the documentation, you will see that readFile
is an "old-school" asynchronous function. It uses a callback instead of returning a promise. We will use util.promisify
to create a new readFile
function that returns a promise.
const readFile = util.promisify(fs.readFile);
Now we can read in the file:
readFile(path.join(__dirname, 'films.json'))
.then((contents)=>{
// Parse the data
// Load it into the films map
// Start the server
const server = app.listen(process.env.PORT || 3001);
console.log('Listening on port %d', server.address().port);
})
.catch((err)=>{
console.error(err);
})
- Parse
contents
into a JavaScript Array usingJSON.parse()
. - Populate the the films Map by iterating over the array. Recall that adding items to a Map is done with the
set(key, value)
function. In this case, thekey
will be theid
of the film and the value will be the film itself. - Move the code that starts the server into the
then
clause so that it doesn't happen until after the data is loaded.
Test the server
If you run npm start
in the top-level directory it will start both the client and the server at the same time (you that can test both). For now we are just focused on the server, so execute npm start
in the server directory (or with the server prefix) to start just the server. You should see the server start listening on port 3001. Verify basic functionality by opening another terminal window and using the curl
utility (may not be available on all platforms). curl
is a command-line tool for performing HTTP (and other) requests. A GET request to "/api/films" should return all of the films. Alternatively, you can open http://localhost:3001/api/films in your browser.
$ curl http://localhost:3001/api/films
[{"adult":false,"backdrop_path":"/dkMD5qlogeRMiEixC4YNPUvax2T.jpg","genre_ids":[28,12,878,53],"id":135397,"original_language":"en","original_title":"Jurassic World","overview":"Twenty-two years after the events of Jurassic Park, Isla Nublar now features a fully functioning dinosaur theme park, Jurassic World, as originally envisioned by John Hammond.","release_date":"2015-06-12","poster_path":"/jjBgi2r5cRt36xF6iNUEhzscEcb.jpg","popularity":46.567302,"title":"Jurassic World","video":false,"vote_average":6.9,"vote_count":2616},{"adult":false,"backdrop_path":"/sEgULSEnywgdSesVHFHpPAbOijl.jpg","genre_ids":[18,12,878],"id":286217,"original_language":"en","original_title":"The Martian","overview":"During a manned mission to Mars, Astronaut Mark Watney is presumed dead after a fierce storm and left behind by his crew. But Watney has survived and finds himself stranded and alone on the hostile planet. With only meager supplies, he must draw upon his ingenuity, wit and spirit to subsist and find a way to signal to Earth that he is alive.","release_date":"2015-10-02","poster_path":"/AjbENYG3b8lhYSkdrWwlhVLRPKR.jpg","popularity":40.509541,"title":"The Martian","video":false,"vote_average":7.7,"vote_count":447},
...
Serve a single film
For each route in the API, we will create a separate Express route. Add a second route to fetch individual films. Since this is another GET request, use app.get
again, but this time the route is '/api/films/:id'
. Note the :id
. This creates a parameter that is accessible via request.params.id
. This is read as a string, so we will convert it to an integer like this:
const filmId = parseInt(request.params.id, 10);
Use filmId
to lookup the film in the Map (use the get()
method), and return it via response.send()
.
Test this is working by checking some ids (e.g., http://localhost:3001/api/films/11).
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.
To make our lives easier, we will install a piece of middleware to help us parse request bodies (i.e. request bodies encoded as 'Content-type': 'application/json'
).
- Install the middleware package:
npm install --save body-parser
(if you are in server directory), ornpm install --save body-parser --prefix server
(if you are in the root directory). Don't forget the--save
option to tellnpm
to record the dependency in thepackage.json
file (so that when someone else installs your application they have all the dependencies). When you update thepackage.json
file you will need to restart the application. - Require the package in
routes.js
:const bodyParser = require('body-parser');
-
Load the middleware into
app
before any of your routes (the order matters, Express executes routes and middleware in the order in which they are declared):app.use(bodyParser.json());
- Now, you can add the final route:
app.put('/api/films/:id', (request, response) => {
const filmId = parseInt(request.params.id, 10);
const newFilm = request.body; // read the modified film out of the request
const mergedFilm = { ...films.get(filmId), ...newFilm}; // merge it with local copy
films.set(mergedFilm.id, mergedFilm); // add the new film back into the collection
response.send(mergedFilm); // return the new film to the user
});
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 Client Application
An obvious approach is to launch the client and see if all of its functionality is present.
We have previously shown you how to set the proxy for a CRA development server so the application can interact with a different server. You will see that in the included Film Explorer client, we have already done this for you. We have also modified the package.json
scripts in the root directory, so when you type npm start
, it will launch both the CRA server and the Node server concurrently. Try this, you should have persistent ratings.
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 Express server 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 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.
Add the final test to check if the parameterized GET route functions properly. You can run the server tests with npm test
(in the server directory) or npm test --prefix server
(from the root directory):
Deploy with Heroku
Our deployed application should not use the development server provided by create-react-app
. Instead, we would like our new server to take over and serve not just the api, but the static files that make up the site as well.
Our process will export our client into a build
folder. We just need to tell the server where to find it and to use it as a source for routes it otherwise doesn't handle explicitly. We will do this with a piece of middleware and a new route.
// express only serves static assets in production
if (process.env.NODE_ENV === 'production') {
// Resolve client build directory as absolute path to avoid errors in express
const buildPath = path.resolve(__dirname, '../client/build');
app.use(express.static(buildPath));
app.get('/', (request, response) => {
response.sendFile(path.join(buildPath, 'index.html'));
});
}
This block checks if we are in a production environment. If we are, we st the build
directory of the client as the source of static files, and then we add a path connecting the root path ("/") to the base file of our exported client (index.html
). Add this before your other routes. You also need to import the path
module: const path = require('path');
.
You are now ready to deploy your application to Heroku. When getting started, you should have signed up for a Heroku account and installed the command line tool.
Create your application via
heroku app:create
then push your application to Heroku to deploy your new color picker
git push heroku master
and then open your newly deployed application
heroku open
The README file of the starter code includes more details of the changes to the repository that were made to support those simple commands.
Finishing Up
- Add and commit your changes to Github. Make sure to add and commit the new files you created.
- Submit your repository to Gradescope
Grading
Points | Requirement |
---|---|
✓/✗ | Unparameterized GET request works |
✓/✗ | Parameterized GET request works |
✓/✗ | PUT request works |
✓/✗ | Test for parameterized GET |
✓/✗ | Passes all ESLint checks (in both client and server) |