Welcome to this course in Web Development and API Design. In this course, we will look at creating single-page applications with React backed by APIs implemented with React. The application will store data in MongoDB and be deployed on Heroku
In this course, we expect you to become proficient at building web applications with JavaScript, React and Express. During the lectures, you will see live coding of how such applications may be constructed and many topics will be explained along the way.
The course will not have slides, but all the lectures will be recorded and made available on Canvas. Each lecture will consist of 10-15 commits which will be availble on Github for student's reference.
There are many topics that we believe are more suitable for self-study than classroom explanations and you will not always be shown how all topics are used in a more general situation. You will be expected to master some such topics to get a top grade at the exam. In order to be prepared for the exam, you have o follow the lectures, but you also have to be able to solve new problems and find relevant information along the way. To be able to do this, it's extremely valuable for you to follow the exercises along the lectures.
We explore the most important parts to the whole application up and running on a server. This lecture will be way to fast to understand and will serve merely as a teaser to topics that will be important through the course. After the lecture, you will only be expected to know the basics of how to create a React application with Parcel and React Router
We will review the React topics from the last lecture: Creating a React app, creating functional components and using props, state and effects. We will also explore React Router more in depth
We add tests for the React code and run the test on Github Actions
- Commit log from live coding
- Reference implementation
- Exercise answer
- Fireship: React in 100 seconds
- Fireship: every React hook
Jest, Github Actions, Prettier, Eslint and Typescript
Express and supertest
- Commit log from live coding
- Reference implementation
- Exercise text
- Exercise answer
- Fireship.io intro til Express
- Using Github API
When creating a project, make sure you add node_modules
, .parcel-cache
and dist
to .gitignore
From root of project:
mkdir client
cd client
npm init -y
npm install --save-dev parcel
npm install --save react react-dom react-router-dom
- Add the following
"script"
inpackage.json
:"dev": "parcel index.html"
- Create a minimal HTML file as
index.html
. This is the essence:<html><body><div id="app"></div></body><script src="index.jsx" type="module"></script></html>
- Create a minimal
index.jsx
. In addition to importing React and ReactDOM, this is the essence:ReactDOM.render(<h1>Hello World</h1>, document.getElementById("app"));
- Run the application with
npm run dev
From root of project:
mkdir server
cd server
npm init -y
npm install --save express
npm install --save-dev nodemon
- Add the following
"script"
inpackage.json
:"dev": "nodemon server.js"
- Set
"type": "module"
inpackage.json
- Create a minimal JavaScript file as
server.js
. This is the essence:import express from "express";
const app = express();
app.listen(process.env.PORT || 3000);
From root of project:
npm init -y
npm install --save-dev concurrently
- Add the following "scripts" in
package.json
:"dev": "concurrently npm:dev:server npm:dev:client"
"dev:server": "cd server && npm run dev"
"dev:client": "cd client && npm run dev"
- Start everything with
npm run start
- In root
package.json
, define correct version of Node and NPM:"engines": { "node": "16.13.1", "npm": "8.3.0" }
- In root
package.json
, createbuild
script for Heroku:"build": "npm run build:client && npm run build:server
,"build:client": "cd client && npm install --include=dev && npm run build"
"build:server": "cd client && npm install
- In client
package.json
, createbuild
script:"build": "parcel build index.html"
- In root
package.json
, createstart
script for Heroku:"start": "cd server && npm start"
- In server
package.json
, createstart
script:"start": "node server.js"
When you can get this to work, you will need to master the following:
- Serve the frontend code from Express. In
server.js
:app.use(express.static(path.resolve(__dir, "..", "..", "dist")));
- Use React Router in front-end
- Make React call API calls on the backend (using
fetch
) - Make Express respond to API calls
export function MoviesApplication() {
return <BrowserRouter>
<Routes>
<Route path={"/"} element={<FrontPage/>}/>
<Route path={"/movies/*"} element={<Movies />}/>
</Routes>
</BrowserRouter>;
}
function Movies() {
return <Routes>
<Route path={""} element={<ListMovies movies={movies}/>}/>
<Route path={"new"} element={<NewMovie onAddMovie={handleAddMovie}/>}/>
</Routes>
}
function FrontPage() {
return <div>
<h1>Front Page</h1>
<ul>
<li><Link to={"/movies"}>List existing movies</Link></li>
<li><Link to={"/movies/new"}>Add new movie</Link></li>
</ul>
</div>;
}
app.use((req, res, next) => {
if (req.method === "GET") {
// TODO: We probably should return 404 instead of index.html for api-calls as well
res.sendFile(path.resolve(__dirname, "..", "..", "dist", "index.html"));
} else {
next();
}
});
export function useLoading(loadingFunction, deps = []) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState();
async function load() {
setLoading(true);
setData(undefined);
setError(undefined);
try {
setData(await loadingFunction());
} catch (e) {
// TODO: Deal with errors
} finally {
setLoading(false);
}
}
useEffect(load, deps);
return { loading, data };
}
When using test, we need to add some babel mumbo jumbo to get Jest to understand modern JavaScript syntax as well as JSX tags
npm install -D jest babel-jest
You need the following fragment or similar in package.json
:
"babel": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
"@babel/preset-react"
]
}
With this in place, it should be possible to run tests like those below.
it("loads book", async () => {
// Fake data instead of calling the real backend
const getBook = () => ({
title: "My Book",
author: "Firstname Lastname",
year: 1999,
});
// Construct an artification dom element to display the app (with jsdom)
const container = document.createElement("div");
// The act method from react-dom/test-utils ensures that promises are resolved
// - that is, we wait until the `getBook` function returns a value
await act(async () => {
await ReactDOM.render(
<!-- construct an object with the necessary wrapping - in our case, routing -->
<MemoryRouter initialEntries={["/books/12/edit"]}>
<Route path={"/books/:id/edit"}>
<!-- use shorthand properties to construct an api object with
getBook property with the getBook function
-->
<EditBookPage bookApi={{ getBook }} />
</Route>
</MemoryRouter>,
container
);
});
// Snapshot tests fail if the page is changed in any way - intentionally or non-intentionally
expect(container.innerHTML).toMatchSnapshot();
// querySelector can be used to find dom elements in order to make assertions
expect(container.querySelector("h1").textContent).toEqual("Edit book: My Book")
});
it("updates book on submit", async () => {
const getBook = () => ({
title: "My Book",
author: "Firstname Lastname",
year: 1999,
});
// We create a mock function. Instead of having functionality,
// this fake implementation of updateBook() lets us record and
// make assertions about the calls to the function
const updateBook = jest.fn();
const container = document.createElement("div");
await act(async () => {
await ReactDOM.render(
<MemoryRouter initialEntries={["/books/12/edit"]}>
<Route path={"/books/:id/edit"}>
<EditBookPage bookApi={{ getBook, updateBook }} />
</Route>
</MemoryRouter>,
container
);
});
// The simulate function lets us create artificatial events, such as
// a change event (which will trigger the `onChange` handler of our
// component
Simulate.change(container.querySelector("input"), {
// The object we pass must work with e.target.value in the event handler
target: {
value: "New Value",
},
});
Simulate.submit(container.querySelector("form"));
// We check that the call to `updateBook` is as expected
// The value "12" is from MemoryRouter intialEntries
expect(updateBook).toHaveBeenCalledWith("12", {
title: "New Value",
author: "Firstname Lastname",
year: 1999,
});
});
const request = require("supertest");
const express = require("express");
const app = express();
app.use(require("body-parser").json());
app.use(require("../src/server/booksApi"));
describe("...", () => {
it("can update existing books", async () => {
const book = (await request(app).get("/2")).body;
const updated = {
...book,
author: "Egner",
};
await request(app).put("/2").send(updated).expect(200);
await request(app)
.get("/2")
.then((response) => {
expect(response.body).toMatchObject({
id: 2,
author: "Egner",
});
});
});
});
// Connect to ws on the same host as we got the frontend
const ws = new WebSocket("ws://" + window.location.host);
// log out the message and destructor the contents when we receive it
ws.onmessage = (msg) => {
console.log(msg);
const { username, message, id } = JSON.parse(msg.data);
};
// send a new message
ws.send(JSON.stringify({username: "Myself", message: "Hello"}));
// Create a websocket server
const wsServer = new ws.Server({ noServer: true });
// Keep a list of all incomings connections
const sockets = [];
let messageIndex = 0;
wsServer.on("connection", (socket) => {
// Add this connection to the list of connections
sockets.push(socket);
// Set up the handling of messages from this sockets
socket.on("message", (msg) => {
// Destructor the incoming message
const { username, message } = JSON.parse(msg);
// Add fields from server side
const id = messageIndex++;
// broadcast a new message to all recipients
for (const recipient of sockets) {
recipient.send(JSON.stringify({ id, username, message }));
}
});
});
// Start express app
const server = app.listen(3000, () => {
// Handle incoming clients
server.on("upgrade", (req, socket, head) => {
wsServer.handleUpgrade(req, socket, head, (socket) => {
// This will pass control to `wsServer.on("connection")`
wsServer.emit("connection", socket, req);
});
});
});
"Implicit flow" means that the login provider (Google) will not require a client secret to complete the authentication. This is often not recommended, and for example Active Directory instead uses another mechanism called PKCE, which protects against some security risks.
- Set up the application in Google Cloud Console. Create a new OAuth client ID and select Web Application. Make sure
http://localhost:3000
is added as an Authorized JavaScript origin andhttp://localhost:3000/callback
is an authorized redirect URI - To start authentication, redirect the browser (see code below)
- To complete the authentication, pick up the
access_token
when Google redirects the browser back (see code below) - Save the
access_token
(e.g. inlocalStorage
) and add as a header to all requests to backend
export function Login() {
async function handleStartLogin() {
// Get the location of endpoints from Google
const { authorization_endpoint } = await fetchJson(
"https://accounts.google.com/.well-known/openid-configuration"
);
// Tell Google how to authentication
const query = new URLSearchParams({
response_type: "token",
scope: "openid profile email",
client_id:
"<get this from Google Cloud Console>",
// Tell user to come back to http://localhost:3000/callback when logged in
redirect_uri: window.location.origin + "/callback",
});
// Redirect the browser to log in
window.location.href = authorization_endpoint + "?" + query;
}
return <button onClick={handleStartLogin}>Log in</button>;
}
In the case of Active Directory, you also need parameters response_type: "code"
, response_mode: "fragment"
, code_challenge_method
and code_challenge
(the latest two are needed for PKCE).
// Router should take user here on /callback
export function CompleteLoginPage({onComplete}) {
// Given an URL like http://localhost:3000/callback#access_token=sdlgnsoln&foo=bar,
// window.location.hash will give the part starting with "#"
// ...substring(1) will remove the "#"
// and Object.fromEntries(new URLSearchParams(...)) will parse it into an object
// In this case, hash = { access_token: "sdlgnsoln", foo: "bar" }
const hash = Object.fromEntries(
new URLSearchParams(window.location.hash.substr(1))
);
// Get the values returned from the login provider. For Active Directory,
// this will be more complex
const { access_token, error } = hash;
useEffect(() => {
// Send the access token back to the outside application. This should
// be saved to localStorage and then redirect the user
onComplete({access_token});
}, [access_token]);
if (error) {
// deal with the user failing to log in or to give consent with Google
}
return <div>Completing loging...</div>;
}
For Active Directory, the hash will instead include a code
, which you will then need to send to the token_endpoint
along with the client_id
and redirect_uri
as well as grant_type: "authorization_code"
and the code_verifier
value from PKCE. This call will return the access_token
.
app.use(async (req, res, next) => {
const authorization = req.header("Authorization");
if (authorization) {
const { userinfo_endpoint } = await fetchJSON(
"https://accounts.google.com/.well-known/openid-configuration"
);
req.userinfo = await fetchJSON(userinfo_endpoint, {
headers: { authorization },
});
}
next();
});
app.get("/profile", (req, res) => {
if (!req.userinfo) {
return res.send(200);
}
});
The first lectures of this course (as of 2021) are documented on Andrea's Github page for the course. Here, you will find slides and exercises.
For lecture 7-12, the current Github repository contains the code that was presented during the lectures. Each lecture contains slides (from Andrea), a commit log for the live coding demonstrated during the lecture, a reference implementation of the live code objective and the Github issues resolved during the lecture.
The lecture covers the "book application" and introduced React Hooks and Parcel
The lecture continued the "book application" and repeated testing with modern React
The lecture starts a new minimal React + Express application and implements https, cookies and sessions
The lecture uses Passport to login with password and with Google and also shows how to implement OpenID Connect "manually" in the front-end. We also covered Cross Origin Resource Sharing (CORS) to access an API on another host/port than the client.
In this lecture, we cover testing that React components render correctly, that button clicks and inputs have the desired effect and that Express responds correctly to API calls.
The lecture continues with the code from lecture 8
- Issues resolved
- Commit log from live coding
- Reference implementation
- Commit log from live exercise rehearsal
In this lecture, we cover more real-time communication between server and clients using WebSockets. We will also revisit testing of the client in the context of this application.
This lecture starts with a new React + Express application