
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.
- Express + TypeScript - konfiguracja projektu
- Express + TypeScript - ESLint i Prettier
- Express + TypeScript - CRUD boilerplate
- Express + TypeScript - struktura aplikacji
- Express + TypeScript - konfiguracja MongoDB
- Express + TypeScript - walidacja requestów z biblioteką Joi
- Express + TypeScript - middlewares aplikacji
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.
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 .
{
"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.
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:
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.
// 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:
// 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.
$ 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.
$ 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.
// 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.
$ 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.
// 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.
// 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.
$ 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.