By the end of this section, you should know how to:
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.
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...
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!
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 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.
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.
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!
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.
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();
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:
npm install
nodemon yourAPI.js
to run the server.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.
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.
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.
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.
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
}
]
}
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.
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.
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.
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.