Setting up a Webapp – Part 2: Express.js Setup

Veröffentlicht von

In the last post we set up a project for our new webapp and configured git, typescript and npm. In this post we’ll write the boilerplate required to run a webserver and accept API requests.

Installing Required Packages

Our app will need some basic packages to provide us with some basic functionality. We’ll start with the following:

npm i -S express compression cors helmet nocache

Later there will be many more packages to be installed. Additionally we’ll need some dev dependencies such as typescript and the typings for our previously installed packages.

npm i -D typescript @types/express @types/compression @types/cors @types/helmet

With these packages installed, we can move on to write the first lines of code.

Basic Express App

This is where the fun begins. I’m sure most readers will know how to setup a basic express.js app, so I won’t go into too much depth here. We’ll start with a simple express app to check that everything works.

import express from "express";

const app = express();

app.get("/", (req, res, next) => res.json({message: "hello world"}));

app.listen(3000, () => {
	console.log("Listening on port 3k!");
});

Let’s use typescript to transpile this code to js and then run that.

tsc -p .
node .

If you visit http://localhost:3000/ in your browser you should see something like this:

Fig. 1: JSON response of our express app (Firefox 88)

Even with a simple webapp like this, there is a lot of room for improvement even in such a simple app. It’s not really necessary yet, but it won’t hurt to do it now. Earlier we installed several middlewares that can help us here:

import express from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";

const app = express();

app.use(helmet());
app.use(cors());
app.use(compression());

app.get("/", (req, res, next) => res.json({message: "hello world"}));

app.listen(3000, () => {
	console.log("Listening on port 3k!");
});

What do these middlewares accomplish? Well, let’s start with helmet.js. It hardens the express app against some attacks by setting a few security related headers. I won’t go into detail here, I recommend you take a look at the documentation. If you intend to ever let your app face the public internet you should definitely go through the documentation and learn about every header helmet.js touches.

The next middleware is cors. It does what the name suggests: It enables CORS. CORS is an acronym for Cross-Origin Resource Sharing. By default other origins (aka other webapps that live at different URLs) can’t access your API. The cors middleware sets the „access-control-allow-origin“ header to „*“, which allows every origin to access your API, even those residing at different origins. You might not want that for your app, in which case you can skip cors or learn about its config options. However in my case i want to allow other origins to access my API.

The last middleware is compression. It’s pretty obvious what it does: It compresses responses to save bandwidth.

Setting Up the Project Structure

Now let’s get started with a basic directory structure to manage all the files we’ll create soon. Usually I structure all my projects like this:

Fig. 1: express.js project structure

The endpoints directory contains all API endpoints for the express app. Often this directory is called controllers, but I put websocket and GraphQL API endpoints in there too. So „controllers“ doesn’t fit that well. In the end it comes down to personal preference. (And in fact, you might see this change later on in this blog series)

services contains services… duh… (I’ll get into the app architecture in a later post).

repositories contains repositories and interfaces to represent my data model.

routes contains pictures of cute kittens (and possibly also routes).

middleware contains middleware and is further split into body validation middleware and URL parameter middleware.

Lastly there is utils which is a catch-all for small things that don’t really belong into any of the other directories, but also aren’t important enough to warrant placing them in the root directory. Usually I’ll add a subdirectory setup/, which contains scripts to setup databases, admin accounts, etc.

The eagle-eyed readers might have noticed that there are 2 additional files I didn’t mention: dev.docker-compose.yml and readme.md. The latter of the two is a simple markdown readme file to explain the purpose and contents of the repository. The docker-compose file sets up all the services we need for our service during development. I’ll go into more detail in the next part of this blog series.

Our First HTTP Endpoint

Let’s actually use this project structure from end to end. We’ll create a HTTP(s) endpoint /book/somebookname that takes GET requests, parses the URL parameter to get a book name and then returns a review for that book. First we need a router that tells express that this route exists and what endpoint to route the request to:

import { Router } from "express";

import { bookNameParameter } from "../middleware/parameters/bookNameParameter";
import { getBookInfo } from "../endpoints/book/";

export function getBooksRouter() {
	const router = Router();
	
	router.get("/book/:bookName", [
		bookNameParameter,
		getBookInfo
	]);

	return router;
}

In theory we could parse the URL parameter in the controller. In practice it makes sense to do that in a separate middleware that can be reused. That’s what bookNameParameter is: A middleware to parse the book name parameter.

If you’re used to express.js you might find my usage of bookNameParameter a bit weird. After all, there is router.param(), so why not use that? In my opinion it has a serious design flaw: If you use it, you lose control over the order the middleware is executed in. I like to retain full control over that order, so I use middlewares for all URL parameters. That way I can specify exactly which order they should run in in the handlers of router.get().

Another thing that might seem unusual is that I don’t export the router directly, but wrap it in a function instead. The reason for that is the same as before: I want to retain control over the order things happen in. If I don’t use a function and instead put everything directly in the file, it gets executed the moment the file is imported, which might not be the moment I want it to execute. With a function I can call it when need it. The logic behind this will get more apparent when we start to do some logging later on in this series.

To use our router, we modify the index.ts to use our fresh, new router:

import express from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
import { getBookRouter } from "./routes";

const app = express();

app.use(helmet());
app.use(cors());
app.use(compression());

app.use(getBookRouter());

app.listen(3000, () => {
	console.log("Listening on port 3k!");
});

If you remember from above, our get route was defined like this:

router.get("/book/:bookName", [
  bookNameParameter,
  getBookInfo
]);

First we route the request through bookParameterName, which looks for the book parameter and hits our database to ask for a book review for this book. We also handle 404s here. I like this approach of handling all errors as early as possible and keeping (most) error handling out of the controller, because it allows for super simple and straightforward controller code.

import { NextFunction, Request, Response } from "express";
import { findBookReview } from "../../repositories/bookReviewRepository";

export async function bookNameParameter(req: Request, res: Response, next: NextFunction) {
	
	const book = await findBookReview(req.params.bookName);

	// Error handling, if there no such book
	if(!book) {
		res.status(404).json({
			error: {
				code: 404,
				msg: `Book review for "${req.params.bookName}" doesn't exist.`
			}
		});
		return;
	}

	req.book = book;
	next();
}

findBookReview() returns a promise. In this simple case it doesn’t have to be that way, but usually we’d asynchronously hit a database in our repository. It’s not particularly interesting, but for completeness sake, here it is:

const reviews = [
	{ title: "dune", review: "Currently reading it, might be good." },
	{ title: "animalfarm", review: "Makes you think. Cool book." },
	{ title: "foundation", review: "Pretty good book, I like it a lot." },
	{ title: "superintelligence", review: "Waaay over my head. Probably a good book, but not for me." }
]


export async function findBookReview(bookTitle: string): Promise<{title: string, review: string}> {
	const review = reviews.filter((item) => item.title == bookTitle)[0];

	return Promise.resolve(review);
}

It’s a basic dummy repository, but don’t worry, we’ll have a real repository with access to postgres later in this series.

Due to the early error checking in bookNameParameter, our controller is extraordinarily simple:

import { NextFunction, Request, Response } from "express";

export function getBookInfo(req: Request, res: Response, next: NextFunction) {
	res.json({
		title: req.book!.title,
		review: req.book!.review
	});
}

Of course I also need to extend the express request interface to fix the various typescript errors. It’s pretty simple too:

import express from "express";

declare module "express" {
    interface Request {
		book?: { title: string, review: string };
	}
}

Now we can compile our app with tsc -p . and then run it with node .. When you visit http://localhost:3000/books/dune you should see something like this:

Fig. 2: Response of the app to our request

If you mistype and try to get a review for a book that doesn’t exist you get this:

Fig. 3: 404 response due to mistyped URL

We now got a pretty good foundation upon which we can build the rest of our app. In the next part we’ll setup and connect our app to a PostgreSQL database with the help of docker and knex.

Kommentar hinterlassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.