Express + TypeScript - middlewares aplikacji

Wstęp

Middlewares

W frameworku Express middlewares są to funkcje, które wykonywane są w trakcie przetwarzania _requestu przez serwer. Można powiedzieć, że wszystko co ma dostęp do obiektuów req i res i wykonuje się pomiędzy zarejestrowaniem zapytania a zwróceniem odpowiedzi to middleware.

Idąc tym tokiem myślenia obsługa requestów, czyli cały routing, kontrolery to też swego rodzaju middleware. Stąd też, każdy middleware może pełnić kilka funkcji:

  • wywoływać dowolny kod;
  • wprowadać zmiany w obiektach req i res;
  • zakańczać cykl obsługi zapytania;
  • przekazywać wywołanie do kolejnej funkcji middleware;

Jeden z ostatnich dwuch podpunktów musi być jednak spełniony. Jeśli funkcja nie zwróci odpowiedzi na zapytanie, to musi ona przekazać wywołanie do kolejnego middleware, wywołując metodę next().

Funkje middleware można podzielić na trzy podstawowe grupy, ze względu na to gdzie są wywołane i jak szeroki scope zapytań obejmują:

  • application-level middleware
  • route-level middleware
  • error-handling middleware

Application-level middleware

Pierwszym typem middleware są funkcje wywoływane na obiekcie aplikacji poprzez metodę app.use() (lub pokrewne metody związane z metodami HTTP). Mogą one mieć scope związany z jakimś URL-em lub być wywołane dla wszystkich przychodzących zapytań.

Prostym przykładem takiej funkcji jest logger, który będzie odpalany dla każdego requestu, po czym, po wywołaniu własnego kodu, przekaze za pomocą metody next(), kontext wywołania do kolejnego middleware, w tym wypadku do routera.

src/middlewares/logger.middleware.ts
import { NextFunction, Request, Response } from "express";

export const logger = (req: Request, res: Response, next: NextFunction) => {
  const { url, method } = req;
  const time = new Date().toISOString();
  console.log(`${time} - [${method}] ${url}`);
  next();
};

Sama funkcja jest bardzo prosta, wyodrępnia ona interesujace mnie dane z obiektu req, loguje je wraz z chwilowym czasem wywołania, a na końcu przekazuje 'pałeczkę' do kolejnej funkcji za pomocą next().

W src/app.ts wystarczy zaimportować funkcję i przekazać ją wywołaniu app.use(logger). Swoją zrogą, poniżej widać, że middleware o zasięgu aplikacji jest również express.json(), który parsuje (przekształca) body requestu w formacie JSON na zrozumiały na JS i TS obiekt.

src/app.ts
import express from "express";
import booksRouter from "./books/books.routes";
import { logger } from "./middlewares/logger.middleware";

const app = express();
app.use(express.json());

app.use(logger);

app.use("/api/books", booksRouter);

export default app;

Teraz gdy wywołam po sobie kilka różnych zapytań do serwera, w logach zobaczę powiązane z tym logi:

2022-12-29T22:07:43.417Z - [GET] /api/books/63ac4658f8e95c6765f38fbe
2022-12-29T22:08:12.631Z - [GET] /api/books

Middleware tego typu mogą być również wywoływane wewnątrz wywołania metody app.use() obsługującej konkretny routing. W takim wypadku, ze względu na to, że moja aplikacja ma tylko jeden router. Logger middlaeware może zostać użyty w następujacy sposób, nie zmieniając działania aplikacji:

src/app.ts
import express from "express";
import booksRouter from "./books/books.routes";
import { logger } from "./middlewares/logger.middleware";

const app = express();
app.use(express.json());

// app.use(logger);

app.use("/api/books", logger, booksRouter);

export default app;

Dzięki takiemu podejsci możliwe jest podpinanie różnych funkcji w zależności od potrzeb.

Route-level middleware

Jak sama nazwa wskazuje jest to funkcja, która swoje wywołanie ogranicza do pojedyńczego router'a aplikacji. Są to bardzo podobne funkcje, do opisanych wyżej, z tą różnicą, że ich wywołanie odbywa się na obiekcie router a nie app.

Error-handling middlewares

Ten typ middleware przeznaczony jest do obsługi błędów aplikacji w porządany przez użytkownika sposób. Od poprzednich middlewares odróżnia go składnie argumentów, wymaga on bowiem czterech, a nie trzech, mianowicie ( err: ApplicationError, req: Request, res: Response, next: NextFunction).

Budowę takiego ogólno-aplikacyjnego middleware do obsługi błędów zacznę od zbudowania nowej klasy ApplicationError, która rozszerza klasę Error. Celem jest przechowywanie w instancji i przekazywanie dalej, oprócz samego error message również status code związanego z błędem.

src/errors/application.error.ts
export class ApplicationError extends Error {
  public statusCode: number;
  constructor(statusCode: number, message: string) {
    super(message);
    this.statusCode = statusCode;
  }
}

Gdy to już mam, mogę dopisać sam customowy middleware obsługujący błędy aplikacji.

src/middlewares/errorHandler.middleware.ts
import { NextFunction, Request, Response } from "express";
import { ApplicationError } from "../errors/application.error";

export const errorHandler = (
  err: ApplicationError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const errStatus = err.statusCode || 500;
  const errMessage = err.message || "Something went wrong";
  res.status(errStatus).json({
    error: errMessage,
  });
};

Funkcja przyjmuje error i na jego podstawie zwraca response z wyciągniętymi statusCode i message. W wypadku, gdy którejś wartości brakuje, uzupełniam je defaultami. Taki generyczny response związany z błędem, można później rozszerzyć o więcej informacji, np. status code, informacje o powodzeniu requestu, czy chociażby stack_trace błędu.

Teraz mogę dodać nowopowstały middleware do aplikacji.

src/app.ts
import express from "express";
import booksRouter from "./books/books.routes";
import { errorHandler } from "./middlewares/errorHandler.middleware";
import { logger } from "./middlewares/logger.middleware";

const app = express();
app.use(express.json());
app.use(logger);

app.use("/api/books", booksRouter);

app.use(errorHandler);

export default app;

Ostatnim krokiem jest przekazanie każdego błędu aplikcji złapanego w kontrolerze do errorHandler middleware za pomocą metody next(). Z tego też względu, we wszystkich funkcjach w kontrolerze rozszerzam listę przyjmowanych argumentów o next: NextFunction, a w każdym bloku try/catch zamiast zwracać błąd wysyłam go do handlera używając next(error).

src/books/books.controller.ts
import { NextFunction, Request, Response } from "express";
import { ApplicationError } from "../errors/application.error";
import bookService from "./books.service";
import {
  createBookValidator,
  updateBookValidator,
} from "./utils/validation.utils";

const getAllBooksHandler = async (req: Request, res: Response, next: NextFunction) => {
  try {
    res.json({ data: await bookService.getAllBooks() });
  } catch (error) {
    next(error);
  }
};

const getBookByIdHandler = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  try {
    const response = await bookService.getBookById(id);
    if (!response) {
      next(new ApplicationError(404, "Book not found"));
    }
    res.json({ data: response });
  } catch (error) {
    next(error);
  }
};

const createBookHandler = async (req: Request, res: Response, next: NextFunction) => {
  const { title, author, published, cover } = req.body;
  const { error } = createBookValidator({ title, author, published, cover });
  if (error) {
    next(
      new ApplicationError(400, error.details.map((err) => err.message).join(","))
    );
  }

  try {
    const response = await bookService.createBook({
      title,
      author,
      published,
      cover,
    });

    res.status(201).json({
      data: response,
    });
  } catch (error) {
    next(error);
  }
};

const updateBookByIdHandler = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  const { title, author, published, cover } = req.body;
  const { error } = updateBookValidator({ title, author, published, cover });
  if (error) {
    next(
      new ApplicationError(400, error.details.map((err) => err.message).join(","))
    );
  }
  try {
    const response = await bookService.updateBookById(id, { title, author, published, cover });
    res.status(200).json({ data: response });
  } catch (error) {
    next(error);
  }
};

const deleteBookByIdHandler = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  try {
    await bookService.deleteBookById(id);
    res.status(204);
  } catch (error) {
    next(error);
  }
};

export default {
  getAllBooksHandler,
  getBookByIdHandler,
  createBookHandler,
  updateBookByIdHandler,
  deleteBookByIdHandler,
};

Warto zwrócić uwagę na sposób obsługi błędów wynikających z logiki aplikacji. W metodzie getBookByIdHandler(), gdy nie znajduję rekordu o podanym id, przy obecnym sposobie obsługi błędów, wystarczy, że utworzę nową instancję ApplicationError przekazując do konstruktora porządany status code i error message jako argumenty i przekażę ją do zhandlowania dalej metodą next().

src/books/books.controller.ts
const getBookByIdHandler = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  try {
    const response = await bookService.getBookById(id);
    if (!response) {
      next(new ApplicationError(404, "Book not found"));
    }
    res.json({ data: response });
  } catch (error) {
    next(error);
  }
};

Tak samo ma się sprawa w metodach createBookHandler i updateBookByIdHandler.

Bonus - przydatne 3rd party middlewares

morgan

morgan jest loggerem dla aplikacji opartych o Node. Aby zainstalować paczkę wraz ze wsparciem dla TypeScript w terminalu uruchamiam:

terminal
npm i morgan
npm i --save-dev @types/morgan

Używa się go jak innych application-level middlewares, wywołując app.use(morgan("<format>")).

src/app.ts
import express from "express";
import morgan from "morgan";
import booksRouter from "./books/books.routes";
import { errorHandler } from "./middlewares/errorHandler.middleware";

const app = express();
app.use(express.json());
app.use(morgan("tiny"));

app.use("/api/books", booksRouter);

app.use(errorHandler);

export default app;

Jako logger zwraca on znacznie więcej użytecznych informacji. W porównaniu do customowego logger, morgan("tiny") - argument oznacza format prezentacji logów, więcej w dokumentacji - zwraca oprócz samych informacji o request również dane z response takie jak np. status code czy długość trwania zapytania.

GET /api/books 200 202 - 6.439 ms
GET /api/books/63ac4658f8e95c6765f38fbe 200 100 - 3.393 ms

helmet

Jest to zbiór małych middlewares odpowiadajacych za zabezpieczenie aplikacji poprzez ustawienie czy modyfikację wielu HTTP Headers. Celem jest zabezpieczenie znanych i popularnych luk w zabezpieczeniach.

Instalujemy przy pomocy:

terminal
npm i helmet

Użycie wygląda identycznie jak w przypadku innych zewnętrznych middlewares: app.use(helmet()).

cors

Jest to middleware pozwalające na zarządzanie polityką CORS (cross-origin resource sharing) w aplikacji Express.

terminal
npm i cors
npm i --save-dev @types/cors

Po szczegółowe informacje i przykłady użycia odsyłam do dokumentacji cors.

Podsumowanie

W taki oto sposób, przybliżyłem sposób działania i zamysł leżący za funkcjami middlewares frameworku Express. Opisałem do tego ciekawe sposoby ich zastosowania: logger zwracający w logach aplikacji informacje o zapytaniach do serwera, oraz errorHandler dzięki któremu obsługa błędów aplikacji jest generyczna, prosta i znajduje się w jednym miejscu.

Jak zwykle kod związany z powyższym artykułem znajduje się w repozytorium github