Section 13: Async, Await, and File I/O

Section Goals

By the end of this section, you should know how to:

  • Read and Write to files asynchronously with async/await
  • Design an API with multiple GET endpoints
  • Handle potential errors in your serverside program with try/catch

Reading a file with callbacks

To read and write files, we first need the FileSystem package

const fs = require("fs");

We can then read a file like so:

fs.readFile("example.txt", "utf8", (err, contents) => {
  if (err) {
    handleError(err);
  } else {
    printContents(contents);
  }
});

The first argument is the relative path to the file. The second argument is the character encoding. The third one is a callback which takes the error (if there is any) and the contents of the file. Once the file is done being read, this callback is called. You may get an error if the file does not exist, so it's important to handle it.

Reading a file with promises

We can "promisify" the readFile function so that it returns a promise instead of taking a callback. To do so, we need the util package.

const util = require("util");

const readFile = util.promisify(fs.readFile);

Now we can call the readFile function and it will return a promise:

let readFilePromise = readFile("example.txt", "utf8");
readFilePromise
  .then(printContents)
  .then(() => { console.log("Done reading file!"); })
  .catch(handleError);

The contents will be passed to the first function in the .then chain. This is a lot cleaner, but it would be nice if we could avoid the promise code and the callbacks entirely, and just store the contents in a variable...

Reading files "synchronously" (but not really)

Using await, we can store the contents in a variable asynchronously, but in a way that looks synchronous.

let contents = await readFile("example.txt", "utf8");
printContents(contents);
console.log("Done reading file!");

await is a fancy keyword that you put before a promise. It will stop running the function and wait for the promise to be fulfilled (allowing other code to run in the meantime.) When the promise is fulfilled, it will jump back to that spot and return the value of the promise. Magically, it removes the callbacks!

Handling errors with await

await has no catches. Literally! We make the promise code much nicer, but we lose the ability to catch errors. We need to do this manually using a "try-catch block":

try {
  const contents = await readFile("example.txt", "utf8");
  printContents(contents);
  console.log("Done reading file!");
} catch (error) {
  handleError(error);
}

It runs the code in the try. If an error occurs, rather than crashing the program, it passes the error to the catch block and goes from there.

This is the main drawback of using await. If you ever use await you must handle potential errors like this. In the main callback for each endpoint, be sure to try/catch any asynchronous await calls.

Writing to files

Writing to files is the same, but with slightly different arguments:

fs.writeFile("example.txt", "Sample Text", "utf8", (err) => {
  if (err) {
    handleError(err);
  } else {
    console.log("File successfully written to!");
  }
});

The new second argument is the text you are entering into the file. The callback is also different, in that it only takes an error as an argument.

const writeFile = util.promisify(fs.writeFile);

In promise form, the promise will resolve with no value, meaning await will not return anything, it will simply write to the file. The callback or promise forms are usually more convenient than the await version.

Appending to a file

If you want to modify an existing file, rather than overwriting or creating a new one, await is very convenient.

try {
  let contents = await readFile("example.txt", "utf8");
  contents += "\nThis is a new line";
  await writeFile("example.txt", contents, "utf8");
  console.log("Successfully appended to the file!");
} catch (error) {
  handleError(error);
}

This is normally a pain because writing to the file requires having first read it, but that is an asynchronous function. By making it look synchronous, we can do one after the other.

Writing and reading JSON

JSON files are text representations of JSON objects. When you read from them, you will get a big string. When you write to them, you need to write a string. Remember to use JSON.parse(jsonString) to turn a JSON string into an object, and JSON.stringify(jsonObj) to turn a JSON object into a string.

let jsonData = await readFile("example.json", "utf8");
jsonData = JSON.parse(jsonData);
jsonData["name"] = "Constanze Amalie von Braunschbank-Albrechtsberger";
await writeFile("example.json", JSON.stringify(jsonData), "utf8");

Being able to store a bunch of organized data in a file is convenient!

Specifically handling file not found errors

Sometimes we expect to not find a file, and want to do something in that case, like make a new file. To distinguish between file not found errors and other kinds of errors (such as null pointer exceptions) we check the error code:

try {
  contents = await readFile("example.txt", "utf8");
} catch (error) {
  if (error.code === "ENOENT") {
    // Do something
  } else {
    handleError(error);
  }
}

ENOENT unintuitively stands for "Error No Entry." and marks when a something was requested, but was not found.

Promisifying functions with async

Any function that uses await must be made asynchronous, using the async keyword.

async function readFileAsync() {
  try {
    let contents = await readFile("example.txt", "utf8");
    return contents;
  } catch (error) {
    handleError(error);
  }
}

This keyword changes the function so that when it is called, it returns a promise rather than running synchronously. The promise resolves when the function finishes running, and the promise's value is the return value of the function.

This is just like the promisified readFile function. You can call an async function using await to make it look synchronous again.

let contents = await readFileAsync();

Exercise: Tricky Typing Test

Solution Files

If you want to try out the program before we write it, you can download the solution and run it. Don't read the code obviously, because that spoils the whole exercise!

As a reminder, to run node code, you must do the following:

  1. Open a terminal in the directory with the package.json file.
  2. run npm install
  3. run nodemon yourAPI.js to run the server.
  4. Open a browser and enter localhost:8000/yourSite.html to run the page.

Always remember to open the html this way. The client-side JS cannot access the API with a relative path otherwise.

Overview

We will be writing an API to support a typing test. It will provide the words for the test from a dictionary, and will store high scores in a JSON file.

Starter Files

We will write three endpoints in total, as well as an APIDOC.md file to document our API. The HTML, CSS, and client-side JS is all provided, though if you are curious about how to make a simple typing test, take a look!

Additionally, the starter typingAPI.js has the setup code done, as well as a particularly complicated function that isn't really worth implementing.

Part 1: /words

The typing test game retrieves its words from a dictionary, and uses this API to get them. The dictionary file is kept in resources/dictionary.txt.

Add a GET endpoint which sends, as plain text, a space-separated list of random words from the dictionary. It takes an optional GET parameter limit. If limit is not provided, give 250 words. If it is provided, limit results to that many.

Sample request: /words?limit=5

Sample response: hello great platypus bench orichalcum

Respond with a helpful plain text error message if the limit is provided but less than 1, or if an error occurs while reading.

HINT: Split the dictionary file by the regex /\r?\n/. This will handle both Windows \r\n newlines and Mac \n newlines.

Part 2: /highscore

At the end of the game, we display a list of the best WPMs (words per minute) to the user. The scores are stored in resources/scores.json and look like this:

{
  "scores": [
    {
      "name": "sven",
      "score": 60
    },
    {
      "name": "Mowgli",
      "score": 10
    }
  ]
}

Part 2: /highscore Continued

Write a GET endpoint which sends, as JSON, the array stored under scores in the JSON file. There should be an optional GET parameter limit. By default, give all of the scores. If limit is given, only provide at most that many scores.

Sample request: /highscore?limit=2

Sample response: [{name: "Sven", score: 60}, {name: "Mowgli", score: 10}]

The first value in your array should be the best score, going down from there. You can safely assume the JSON file is already in the right order.

Provide a helpful plaintext error message if the limit is provided but less than 1 or if an error occurs while reading.

Part 3: /highscore/:name/:score

Finally, we want users to be able to submit their own scores. Since we have not done POST yet, we will do this with a GET request and URL parameters. Tomorrow we will talk about POST requests.

Given the provided name and score, add the score to the JSON file. This is actually pretty complicated, since we need to update existing scores if they are under the same name, and need to keep the entries sorted. We have provided a addScore function that does most of the work. Pass it the original array (read similarly to how you did it in Part 2) as well as the name and score. It will return the new array with the entry added. Then you just need to repackage it under the "scores" key and write the JSON back into the file.

Part 3: /highscore/:name/:score Continued

Your GET endpoint should respond with a plain text message indicating success:

Sample request: /highscore/Melissa/154

Sample response: Score successfully added!

Give a helpful plain text error message if there is an error while reading/writing.

Test your code by first backing up the original JSON file (in case you destroy it :O) then running this endpoint, then the /highscore endpoint.

Congratulations!

The game is now complete. You can try playing it on your localhost to beat the TAs!

Aside from using GET instead of POST, what is a potential weakness with the way we implemented the highscore system?

Unfortunately, somebody could just send a fake request with a huge score and there is no way to verify that it is fake. This is a MASSIVE problem with highscore systems like this, and not an easy one to solve! There are few leaderboards out there that are not plagued with fake entries. Solving this problem is outside the scope of the class, but it's interesting to mention.