Express + TypeScript - CRUD boilerplate

Wstęp

W tym wpisie pokazuję budowę prostej aplikacji CRUD będącej rozszerzeniem boilerplate budowanego w Express + TypeScript - konfiguracja projektu. Mowa będzie między innymi o routingu i sposobach wyciągania danych wyciąganych z requestów HTTP. Pomijam tu jeszcze zagadnienia baz danych, aby wpis nie był zbyt obszerny. Zostaną one poruszona w dalszych częściach cyklu.

Kod omawiany w artykule można znaleźć w repozytorium github.

Pierwszy server

Sam plik src/index.ts, który powstał w linkowanym we wstępie artykule, jest bardzo prosty i nie muszę go szczegółowo omawiać. Importuje niezbędne elementy z frameworku express, inincjalizuje aplikację, rejestruję prosty handler requestu HTTP i na końcu pozostawiam nasłuch aplikacji na porcie :5000.

src/index.ts
import express, { Request, Response } from "express";

const app = express();

app.get("/", (req: Request, res: Response) => {
  res.send("Hello World!");
});

app.listen(5000, () => {
  console.log("Server started on port 5000!");
});

W package.json również nie ma żadnych skomplikowanych rzeczy, dependencjami mojej aplikacji jest tylko express natomiast jako devDependencies zainstalowane mam rzeczy związane ze wsparciem TypeScript, autoreloadem aplikacji nasłuchującym na zmiany w plikach oraz dodane w poprzednim w artykule paczki zależne od ESLint i Prettier .

package.json
{
  "name": "express-ts",
  "version": "1.0.0",
  "main": "src/index.js",
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/index.js",
    "start:dev": "nodemon src/index.ts",
    "lint": "eslint . --ext .ts",
    "lint:fix": "eslint . --ext .ts --fix"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.14",
    "@types/node": "^18.11.13",
    "@typescript-eslint/eslint-plugin": "^5.48.1",
    "eslint": "^8.31.0",
    "eslint-config-prettier": "^8.6.0",
    "eslint-config-standard-with-typescript": "^27.0.1",
    "eslint-plugin-import": "^2.27.4",
    "eslint-plugin-n": "^15.6.1",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-promise": "^6.1.1",
    "nodemon": "^2.0.20",
    "prettier": "^2.8.2",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.4"
  }
}

Skrypty pozwalają odpowiednio na zbudowanie, wystartowanie zbudowanej oraz włączenie ciągłego nasłuchu na zmiany w plikach w celu odświeżania aplikacji. Zdecydowanie najczęściej będę korzystał z npm run start:dev.

Routing

Aplikacja CRUD

CRUD jest to skrót od Create - Read - Update - Delete, opisujący sposób pisania prostych aplikacji (serwisów) operujących na pewnej, dowolnej encji danych. Aplikacja ma pozwalać na tworzenie, pobierania, zmienianie i usuwanie tych danych.

Rozszerzanie aplikacji rozpoczynam od dopisania prostego obiektu do przechowywania danych, który będzie symulował bazę danych (o podłączeniu i używaniu baz danych w kolejnych wpisach). Będzie on w formie prostej listy zawierającej obiekty o zdefiniowanym type Book , odzwierciedlającym prostą encję książki. Cała service ma symulować uproszczoną aplikację biblioteczną, do której stopniowo będę dodawał funkcjonalności.

src/index.ts
import express from `express`;

const app = express();

const randomId = (): string => Math.random().toString(36).slice(2, 7);

interface Book {
  id: string;
  title: string;
  author: string;
  published: number;
}

const books: Book[] = [
  {
    id: randomId(),
    title: `Lód`,
    author: `Dukaj`,
    published: 2007,
  },
];

// Routing

// GET all books

// GET single book

// POST new book

// PUT (update) book

// DELETE book

app.listen(5000, () => {
  console.log(`Server started on port 5000!`);
});

Poniżej listy books znajduje się wykomentowany, zaproponowany routing aplikacji, czyli pięć podstawowych adresów URL wraz z metodami HTTP, na które aplikacja będzie nasłuchiwała, i dla których będzie wykonywała określone akcje. Tak chyba najprościej można opisać routing serwera, czyli zdefiniowane możliwych zapytań i odpowiedzi na nie.

Protokół HTTP

HTTP (skrót od Hypertext Transfer Protocol) jest najczęściej, w tym momencie, wykorzystywanym protokołem w przeglądarkach. Jest to protokół bezstanowy, czyli ani serwer, ani klient (aplikacja "rozmawiająca" z serwerem) nie przechowuje informacji o zapytaniach. Z punktu widzenia połączenia, każde kolejne zapytanie jest traktowane jak całkowicie nowe. Zapytanie HTTP składa się z head i body. Head definiuje mi. metodę HTTP czy nagłówki przekazane w zapytaniu, a body to nic innego jak dane, które przekazujemy serwerowi.

Podstawowe metody HTTP:

  • GET - pobieranie danych;
  • POST - przesyłanie danych w formacie klucz - wartość;
  • PUT - również przesyłanie pakietu danych, zwykle używana do updatowania konkretnego elementu zasobu;
  • DELETE - usuwanie danych.

Nagłówki HTTP są opcjonalnymi metadanymi wymienianymi między sobą przez przeglądarkę i serwer, w celu przekazania informacji tj. np. rodzaj przesyłanych treści, lub jakiej odpowiedzi oczekuje druga strona. Przyjmują one postać klucz: wartość. Jedynym obecnie dla nas przydatnym nagłówkiem jest "Content-type", określający typ przesyłanych danych.

Statusy HTTP są to trzycyfrowe, numeryczne kody dołączone do odpowiedzi na zapytanie HTTP, sygnalizują jej status (powodzenie, niepowodzenie, jego przyczynę). Zasadniczo statusy HTTP można podzielić na 5 grup, związanych z pierwszą cyfrą kodu:

  • 1xx — rzadko spotykane, informacyjne, mówiące bardziej 0 środowisku;
  • 2xx — powodzenie (np. 200 - OK, 201 - utworzono zasób);
  • 3xx — przekierowanie;
  • 4xx — błąd po stronie użytkownika (np. 404 — nie znaleziono, 403 — brak dostępu lub 400 — niepoprawne zapytanie);
  • 5xx — błąd po stronie serwera.

GET all books

Zacznę od endpointu do pobrania wszystkich książek. Wykorzystamy do tego metodę GET protokołu HTTP. Express umożliwia korzystanie z metod HTTP w bardzo prosty sposób, posiada on tak samo nazywające się metody, w których pierwszy parametr definiuje ścieżkę (URL). Dla zapytania pobierającego dane będzie to app.get("/", ...).

Kolejnym parametrem metody get jest funkcja handler odpowiadająca za obsługę zapytanie, które przyszło na dany URL, która ma trzy parametry (req, res i next), są to odpowiednio obiekt żądania (req - request), obiekt odpowiedzi (res - response), oraz funkcję przekazującą wywołanie do kolejnego handlera. Za ich pomocą jesteśmy w stanie odbierać, jak i przekazywać informacje do metody GET protokołu HTTP. Na temat funkcji next() więcej napiszę w kolejnych artykułach.

Za pomocą metody res.send() obiektu Response mogę wysyłać proste informacje, np. stringi lub tagi html. W tym projekcie zdecydowanie częściej będę wysyłać obiekty w formacie JSON metodą res.json(). Wysłanie bardzo prostego JSON`a wygląda tak:

src/index.ts
app.get(`/`, (req: Request, res: Response) => {
  res.json({
    title: `Lód`,
    author: `Dukaj`,
    published: 2007
  });
});

Gdy teraz w przeglądarce odpalimy http://localhost:5000/ otrzymamy obiekt JSON, zawierający przykładowy obiekt Book. Założeniem aplikacji jest jednak aby korzystała ona z zaproponowanego uproszczonego storage książek. Podstawowym endpointem, jet ten zwracający wszystkie książki przechowywane w liście books. Do tego dobrą praktyką aplikacji opartych o architekturę REST jest również, grupowanie powiązanych ze sobą resources poprzez wspólne URL. Z tego powodu dodam do URL człon mówiący, że jest to api, oraz resource books.

src/index.ts
// GET all books
app.get(`/api/books`, (req: Request, res: Response) => {
  res.json({
    data: books,
  });
});

Wykonując request na adres localhost:5000/api/books otrzymam zawartość obiektu books z jedną książką.

POST book

Kolejnym krokiem będzie dodanie metody pozwalającej na dodawanie nowych książek, korzystającej z metody POST. Przeglądarka nie pozwala na wykonanie takich zapytań (tylko GET). Można się w tym wypadku posłużyć np. aplikacją Postman lub opisanymi w innym wpisie biblioteką cURL czy wtyczką REST Client do VSCode

Zanim jednak dodam pierwszą książkę, muszę zadbać o poprawne parsowanie danych przesyłanych do aplikacji. Wykonane to zostanie za pomocą funkcji middleware, czyli pewnego rodzaju funkcji, która zostaje wywołana dla każdego zapytania do mojej aplikacji, niezależnie od URL i handlera, który obsługuje request. O funkcjach middleware mowa będzie szerzej w kolejnych artykułach. Sam Express ułatwia nam bardzo to zadanie, definiując konkretne metody do parsowania danych. W przypadku tej aplikacji format danych, którego się będę spodziewał jako body requestu, fo JSON, stąd też wywołam middleware app.use(express.json());, linijkę tę wystarczy dodać przed routingiem aplikacji.

Kod pozwalający na obsłużenie dodania nowej książki do zasobu wygląda następująco:

src/index.ts
// POST book
app.post(`/api/books/`, (req: Request, res: Response) => {
  const { title, author, published } = req.body;
  const newBook = {
    id: randomId(),
    title,
    author,
    published,
  };
  books.push(newBook);
  res.status(201).json({
    data: newBook,
  });
});

Na początku za pomocą destrukturyzacji obiektu req.body zawierajacego przekazane w requeście dane tworzę nowe zmienne, odpowiadające polom obiektu Book. Na ich podstawie tworzę obiekt newBook: Book. Jedynie id tworzone jest automatycznie przez funkcję randomId - w późniejszych artykułach okaże się, że tworzenie randomowego identyfikatora leży w gest używanej przeze mnie bazy danych. Gdy mam już nową książkę dołączam ją do tablicy books i zwracam użytkownikowi wraz ze statusem 201 informującym o powodzeniu akcji dodania zasobu.

terminal
$ curl -X POST http://localhost:5000/api/books/ -d `{"title": "Katedra","author": "Dukaj","published": 2000}` -H "Content-Type: application/json"

{
  "data":{
    "id":"ktllz",
    "title":"Katedra",
    "author":"Dukaj",
    "published":2000
  }
}

Zapytanie zwróciło nową książkę wraz z informacjami przekazanymi w requeście oraz unikalnym id. Teraz tylko sprawdzenie, czy książka została poprawnie dodana do storage.

terminal
$ curl localhost:5000/api/books/

{
  "data":[
    {
      "id":"pyozt",
      "title":"Lód",
      "author":"Dukaj",
      "published":2007
    },
    {
      "id":"ktllz",
      "title":"Katedra",
      "author":"Dukaj",
      "published":2000
    }
  ]
}

GET single book

Teraz sprawa dość prosta, mam znaleźć konkretny rekord z storage i go zwrócić. Żeby to zrobić, muszę określić, o który rekord chodzi. Z tego właśnie powodu, zapewniłem każdemu z obiektowi listy books unikalny id, mogę teraz go przekazać w URL a w samej metodzie wyciągnąć z parametrów requestu req.params.

src/index.ts
// GET one book by id
app.get(`/api/books/:id`, (req: Request, res: Response) => {
  const { id } = req.params;
  const book = books.find(item => item.id === id);
  res.json({
    data: book,
  });
});

:id w URLu określa, że z tego miejsca będzie pobierany parametr zapytania o nazwie id. Następnie za pomocą metody books.find() przeszukuję tablicę books i zwracam tylko ten element, który ma id zgodne z :id w przekazanym w adresie zapytania.

terminal
$ curl localhost:5000/api/books/ktllz

{
  "data": {
    "id":"pyozt",
    "title":"Lód",
    "author":"Dukaj",
    "published":2007
  }
}

DELETE book

Gdy już potrafię specyfikować, o który rekord chodzi za pomocą req.params i id, dodam metodę pozwalającą na usunięcie konkretnego rekordu.

src/index.ts
// DELETE book by id
app.delete(`/api/books/:id`, (req: Request, res: Response) => {
  const { id } = req.params;
  books = books.filter(user => user.id !== id);
  res.status(204);
});

Od poprzedniej metody różni się tylko tym, że teraz filtrujemy tablicę, biorąc tylko rekordy różniące się pod względem id od id z URL i nadpisujemy tak przefiltrowaną tablicą oryginalny storage. Jako response nie wysyłam żadnych danych, bo jeśli metoda się powiodła, to nie ma sensu - i tak zostały one już usunięte. Wysyłam jedynie status 204 - No Content, co jest bardzo powszechne, przy akcjach usuwania zasobu.

PUT (update) user

Tutaj będzie trochę trudniej, muszę określić dokładnie który element chcemy zmienić wyciągają z requestu jego id ale również muszę przypisać mu nowe wartości dla sprecyzowanych pól.

src/index.ts
// PUT (update) book by id
app.put(`/api/books/:id`, (req: Request, res: Response) => {
  const { id } = req.params;
  const { title, author, published } = req.body;
  const existingBook = books.find(item => item.id === id);

  if (existingBook === undefined) {
    const newBook = {
      id: randomId(),
      title,
      author,
      published,
    };
    books.push(newBook);
    res.status(201).json({
      data: newBook,
    });
  }

  const updatedBook: Book = {
    id,
    title: title === undefined ? existingBook?.title : title,
    author: author === undefined ? existingBook?.author : author,
    published: published === undefined ? existingBook?.published : published,
  };

  books = books.map(book => {
    if (book.id === id) {
      return updatedBook;
    } else {
      return book;
    }
  });

  res.json({
    data: updatedBook,
  });
});

Na początku wyciągam z parametrów requestu id poszykiwanej ksiązki oraz dane, które chce zupdatować z body. Następnie staram się za pomocą metody books.find() znaleźć rekord do zmiany. Tutaj, jako dodatek, w związku z tym, że używam metody PUT, gdy w storage nie występuje rekord z danym id, pozwalam na jego dodanie (utworzenie nowego rekordu jak przy metodzie POST) zwracając nowy rekord wraz ze statusem 201 i kończę wywołanie handlera za pomocą return.

Gdy rekord jednak istnieje, updatuje pola, które zostały wysłane w body requestu. Żeby endpoint był bardziej uniwersalny, zmieniam tylko te pola, które rzeczywiście zostały przekazane. Tak zmienionym rekordem nadpisuje obiekt w storage i zwracam informacje o powodzeniu wraz z samym zmienionym rekordem jako response.

terminal
$ curl -X PUT http://localhost:5007/api/books/ktllz -d `{"title": "Wroniec","published": 2009}` -H "Content-Type: application/json"

{
  "data":{
    "id":"ktllz",
    "title":"Wroniec",
    "author":"Dukaj",
    "published":2009
  }
}

Podsumowanie

Jak widać, trochę tego tekstu i kodu powstało, a nadal jest to jedno-plikowa aplikacja. Z drugiej strony, właśnie powstała prosta aplikacja CRUD dająca dobry start do dalszego rozwoju i omawiania kolejnych etapów dewelopmentu. Kod powstałej tu aplikacji dostępny w repozytorium. W najbliższym czasie, na bazie tej aplikacji, dodam kolejne wpisy opisujące między innymi integracje z bazami danych, rozszerzenie funkcjonalności o poprawny error-handling, omówienie i wykorzystanie middlewares do poprawy flow aplikacji i nie tylko.