Budowanie interfejsu API REST Node.js/TypeScript, część 2: modele, oprogramowanie pośredniczące i usługi
Opublikowany: 2022-03-11W pierwszym artykule z naszej serii REST API omówiliśmy, jak używać npm do tworzenia zaplecza od podstaw, dodawania zależności, takich jak TypeScript, używania modułu debug wbudowanego w Node.js, budowania struktury projektu Express.js i rejestrowania środowiska wykonawczego wydarzenia elastycznie z Winstonem. Jeśli znasz już te koncepcje, po prostu sklonuj to, przełącz się na toptal-article-01 za pomocą git checkout i czytaj dalej.
Usługi REST API, oprogramowanie pośredniczące, kontrolery i modele
Zgodnie z obietnicą omówimy teraz szczegóły tych modułów:
- Usługi , które sprawiają, że nasz kod jest czystszy, hermetyzując operacje logiki biznesowej w funkcje, które może wywoływać oprogramowanie pośredniczące i kontrolery.
- Oprogramowanie pośredniczące , które sprawdzi poprawność warunków wstępnych, zanim Express.js wywoła odpowiednią funkcję kontrolera.
- Kontrolery korzystające z usług do przetwarzania żądania przed ostatecznym wysłaniem odpowiedzi do żądającego.
- Modele , które opisują nasze dane i pomagają w kontrolach w czasie kompilacji.
Dodamy również bardzo szczątkową bazę danych, która w żaden sposób nie nadaje się do produkcji. (Jego jedynym celem jest ułatwienie śledzenia tego samouczka, torując drogę do naszego następnego artykułu, aby zagłębić się w połączenie z bazą danych i integrację z MongoDB i Mongoose.)
Praktyczne: Pierwsze kroki z DAO, DTO i naszą tymczasową bazą danych
W tej części naszego samouczka nasza baza danych nie będzie nawet używać plików. Będzie po prostu przechowywać dane użytkownika w tablicy, co oznacza, że dane wyparują za każdym razem, gdy opuścimy Node.js. Obsługuje tylko najbardziej podstawowe operacje tworzenia, odczytu, aktualizacji i usuwania (CRUD).
Użyjemy tutaj dwóch pojęć:
- Obiekty dostępu do danych (DAO)
- Obiekty przesyłania danych (DTO)
Ta jednoliterowa różnica między akronimami jest niezbędna: DAO jest odpowiedzialny za łączenie się ze zdefiniowaną bazą danych i wykonywanie operacji CRUD; DTO to obiekt, który przechowuje surowe dane, które DAO wyśle do bazy danych i odbierze z niej.
Innymi słowy, DTO to obiekty, które są zgodne z typami modeli danych, a DAO to usługi, które z nich korzystają.
Chociaż obiekty DTO mogą być bardziej skomplikowane — na przykład reprezentując zagnieżdżone jednostki bazy danych — w tym artykule pojedyncza instancja DTO będzie odpowiadać określonej akcji w pojedynczym wierszu bazy danych.
Dlaczego DTO?
Korzystanie z obiektów DTO w celu dostosowania naszych obiektów TypeScript do naszych modeli danych pomaga zachować spójność architektoniczną, jak zobaczymy w poniższej sekcji poświęconej usługom. Ale jest kluczowe zastrzeżenie: ani DTO, ani sam TypeScript nie obiecują żadnej automatycznej walidacji danych wejściowych użytkownika, ponieważ musiałoby to nastąpić w czasie wykonywania. Gdy nasz kod otrzyma dane wejściowe użytkownika w punkcie końcowym naszego interfejsu API, dane wejściowe mogą:
- Mieć dodatkowe pola
- Brakuje wymaganych pól (tj. tych, które nie są wyposażone w
?) - Mieć pola, w których dane nie są typem określonym w naszym modelu za pomocą TypeScript
TypeScript (i JavaScript, do którego jest transpilowany) nie sprawdzi tego w magiczny sposób, więc ważne jest, aby nie zapomnieć o tych walidacjach, szczególnie podczas otwierania interfejsu API dla publiczności. Pakiety, takie jak ajv, mogą w tym pomóc, ale zwykle działają, definiując modele w obiekcie schematu specyficznym dla biblioteki, a nie natywnym TypeScript. (Podobną rolę w tym projekcie odegra mangusta, o której mowa w następnym artykule).
Być może myślisz: „Czy naprawdę najlepiej jest używać zarówno DAO, jak i DTO, zamiast czegoś prostszego?” Deweloper korporacyjny Gunther Popp oferuje odpowiedź; będziesz chciał uniknąć DTO w większości mniejszych rzeczywistych projektów Express.js/TypeScript, chyba że możesz racjonalnie oczekiwać skalowania w średnim okresie.
Ale nawet jeśli nie zamierzasz używać ich w środowisku produkcyjnym, ten przykładowy projekt jest wartościową okazją na drodze do opanowania architektury API TypeScript. Jest to świetny sposób na przećwiczenie wykorzystywania typów TypeScript na dodatkowe sposoby i pracy z obiektami DTO, aby zobaczyć, jak wypadają one w porównaniu z bardziej podstawowym podejściem podczas dodawania składników i modeli.
Nasz model interfejsu API REST użytkownika na poziomie TypeScript
Najpierw zdefiniujemy trzy DTO dla naszego użytkownika. Stwórzmy folder o nazwie dto wewnątrz folderu users i utwórzmy tam plik o nazwie create.user.dto.ts zawierający następujące elementy:
export interface CreateUserDto { id: string; email: string; password: string; firstName?: string; lastName?: string; permissionLevel?: number; }Mówimy, że za każdym razem, gdy tworzymy użytkownika, niezależnie od bazy danych, powinien on mieć identyfikator, hasło i adres e-mail oraz opcjonalnie imię i nazwisko. Te wymagania mogą się zmieniać w zależności od wymagań biznesowych danego projektu.
W przypadku żądań PUT chcemy zaktualizować cały obiekt, więc nasze opcjonalne pola są teraz wymagane. W tym samym folderze utwórz plik o nazwie put.user.dto.ts z następującym kodem:
export interface PutUserDto { id: string; email: string; password: string; firstName: string; lastName: string; permissionLevel: number; } W przypadku żądań PATCH możemy użyć funkcji Partial z TypeScript, która tworzy nowy typ, kopiując inny typ i czyniąc wszystkie jego pola opcjonalnymi. W ten sposób plik patch.user.dto.ts musi zawierać tylko następujący kod:
import { PutUserDto } from './put.user.dto'; export interface PatchUserDto extends Partial<PutUserDto> {} Teraz utwórzmy tymczasową bazę danych w pamięci. Utwórzmy folder o nazwie daos w folderze users i dodajmy plik o nazwie users.dao.ts .
Najpierw chcemy zaimportować utworzone przez nas DTO:
import { CreateUserDto } from '../dto/create.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; import { PutUserDto } from '../dto/put.user.dto';Teraz, aby obsłużyć nasze identyfikatory użytkowników, dodajmy bibliotekę shortid (za pomocą terminala):
npm i shortid npm i --save-dev @types/shortid Wracając do users.dao.ts , zaimportujemy shortid:
import shortid from 'shortid'; import debug from 'debug'; const log: debug.IDebugger = debug('app:in-memory-dao'); Możemy teraz stworzyć klasę o nazwie UsersDao , która będzie wyglądać tak:
class UsersDao { users: Array<CreateUserDto> = []; constructor() { log('Created new instance of UsersDao'); } } export default new UsersDao(); Używając wzorca singleton, ta klasa zawsze zapewni tę samą instancję — i, co najważniejsze, tę samą tablicę users — gdy zaimportujemy ją do innych plików. Dzieje się tak, ponieważ Node.js buforuje ten plik, gdziekolwiek jest importowany, a wszystkie importy odbywają się podczas uruchamiania. Oznacza to, że każdy plik odwołujący się do users.dao.ts otrzyma odniesienie do tej samej new UsersDao() , która zostanie wyeksportowana przy pierwszym przetworzeniu tego pliku przez Node.js.
Zobaczymy, jak to działa, gdy użyjemy tej klasy w dalszej części tego artykułu i użyjemy tego wspólnego wzorca TypeScript/Express.js dla większości klas w całym projekcie.
Uwaga: Często przytaczaną wadą singletonów jest to, że trudno jest dla nich pisać testy jednostkowe. W przypadku wielu naszych klas ta wada nie będzie miała zastosowania, ponieważ nie ma żadnych zmiennych składowych klasy, które wymagałyby zresetowania. Ale dla tych, w których by tak było, pozostawiamy jako ćwiczenie dla czytelnika rozważenie podejścia do tego problemu za pomocą wstrzykiwania zależności.
Teraz dodamy podstawowe operacje CRUD do klasy jako funkcje. Funkcja tworzenia będzie wyglądać tak:
async addUser(user: CreateUserDto) { user.id = shortid.generate(); this.users.push(user); return user.id; }Czytanie będzie dostępne w dwóch wersjach: „przeczytaj wszystkie zasoby” i „odczytaj jeden według identyfikatora”. Są zakodowane w ten sposób:
async getUsers() { return this.users; } async getUserById(userId: string) { return this.users.find((user: { id: string }) => user.id === userId); } Podobnie aktualizacja będzie oznaczać nadpisanie całego obiektu (jako PUT ) lub tylko części obiektu (jako PATCH ):
async putUserById(userId: string, user: PutUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1, user); return `${user.id} updated via put`; } async patchUserById(userId: string, user: PatchUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); let currentUser = this.users[objIndex]; const allowedPatchFields = [ 'password', 'firstName', 'lastName', 'permissionLevel', ]; for (let field of allowedPatchFields) { if (field in user) { // @ts-ignore currentUser[field] = user[field]; } } this.users.splice(objIndex, 1, currentUser); return `${user.id} patched`; } Jak wspomniano wcześniej, pomimo naszej deklaracji UserDto w tych sygnaturach funkcji, TypeScript nie zapewnia sprawdzania typu w czasie wykonywania. To znaczy że:
-
putUserById()ma błąd. Pozwoli to konsumentom API przechowywać wartości dla pól, które nie są częścią modelu zdefiniowanego przez nasz DTO. -
patchUserById()zależy od zduplikowanej listy nazw pól, które muszą być zsynchronizowane z modelem. Bez tego musiałby użyć aktualizowanego obiektu dla tej listy. Oznaczałoby to, że dyskretnie zignoruje wartości dla pól, które są częścią modelu zdefiniowanego DTO, ale nie zostały wcześniej zapisane dla tego konkretnego wystąpienia obiektu.
Ale oba te scenariusze będą obsługiwane poprawnie na poziomie bazy danych w następnym artykule.
Ostatnia operacja usunięcia zasobu będzie wyglądać tak:
async removeUserById(userId: string) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1); return `${userId} removed`; }Jako bonus, wiedząc, że warunkiem wstępnym utworzenia użytkownika jest sprawdzenie, czy adres e-mail użytkownika nie jest zduplikowany, dodajmy teraz funkcję „pobierz użytkownika przez e-mail”:
async getUserByEmail(email: string) { const objIndex = this.users.findIndex( (obj: { email: string }) => obj.email === email ); let currentUser = this.users[objIndex]; if (currentUser) { return currentUser; } else { return null; } }Uwaga: w rzeczywistym scenariuszu prawdopodobnie połączysz się z bazą danych za pomocą istniejącej biblioteki, takiej jak Mongoose lub Sequelize, która wyabstrahuje wszystkie podstawowe operacje, których możesz potrzebować. Z tego powodu nie będziemy wchodzić w szczegóły funkcji zaimplementowanych powyżej.
Nasza warstwa usług REST API
Teraz, gdy mamy podstawowe DAO w pamięci, możemy stworzyć usługę, która będzie wywoływać funkcje CRUD. Ponieważ funkcje CRUD są czymś, co musi mieć każda usługa, która będzie łączyć się z bazą danych, stworzymy interfejs CRUD , który będzie zawierał metody, które chcemy zaimplementować za każdym razem, gdy chcemy zaimplementować nową usługę.
Obecnie IDE, z którymi pracujemy, mają funkcje generowania kodu, aby dodać implementowane przez nas funkcje, zmniejszając ilość powtarzalnego kodu, który musimy napisać.
Szybki przykład z wykorzystaniem WebStorm IDE:
IDE wyróżnia nazwę klasy MyService i sugeruje następujące opcje:
Opcja „Zaimplementuj wszystkich członków” natychmiast tworzy szkielet funkcji potrzebnych do dostosowania się do interfejsu CRUD :
To wszystko powiedziawszy, najpierw stwórzmy nasz interfejs TypeScript o nazwie CRUD . W naszym common folderze utwórzmy folder o nazwie interfaces i dodajmy crud.interface.ts z następującymi elementami:
export interface CRUD { list: (limit: number, page: number) => Promise<any>; create: (resource: any) => Promise<any>; putById: (id: string, resource: any) => Promise<string>; readById: (id: string) => Promise<any>; deleteById: (id: string) => Promise<string>; patchById: (id: string, resource: any) => Promise<string>; } Po wykonaniu tych czynności utwórzmy folder services w folderze users i dodajmy tam plik users.service.ts zawierający:
import UsersDao from '../daos/users.dao'; import { CRUD } from '../../common/interfaces/crud.interface'; import { CreateUserDto } from '../dto/create.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; class UsersService implements CRUD { async create(resource: CreateUserDto) { return UsersDao.addUser(resource); } async deleteById(id: string) { return UsersDao.removeUserById(id); } async list(limit: number, page: number) { return UsersDao.getUsers(); } async patchById(id: string, resource: PatchUserDto) { return UsersDao.patchUserById(id, resource); } async readById(id: string) { return UsersDao.getUserById(id); } async putById(id: string, resource: PutUserDto) { return UsersDao.putUserById(id, resource); } async getUserByEmail(email: string) { return UsersDao.getUserByEmail(email); } } export default new UsersService(); Naszym pierwszym krokiem tutaj było zaimportowanie naszego obiektu DAO w pamięci, naszej zależności interfejsu i typu TypeScript każdego z naszych DTO. Nadszedł czas na zaimplementowanie UsersService jako singletona usługi, tego samego wzorca, którego używaliśmy w naszym DAO.
Wszystkie funkcje CRUD teraz po prostu wywołują odpowiednie funkcje UsersDao . Kiedy nadejdzie czas na zastąpienie DAO, nie będziemy musieli wprowadzać zmian w żadnym innym miejscu projektu, z wyjątkiem kilku poprawek w tym pliku, w których wywoływane są funkcje DAO, jak zobaczymy w części 3.
Na przykład nie będziemy musieli śledzić każdego wywołania funkcji list() i sprawdzać jej kontekstu przed zastąpieniem go. To jest zaleta posiadania tej warstwy separacji, kosztem niewielkiej ilości początkowego kotła, którą widzisz powyżej.
Async/Await i Node.js
Nasze użycie async dla funkcji serwisowych może wydawać się bezcelowe. Na razie jest tak: Wszystkie te funkcje natychmiast zwracają swoje wartości, bez żadnego wewnętrznego użycia Promise s lub await . Służy to wyłącznie przygotowaniu naszej bazy kodu dla usług, które będą korzystać z async . Podobnie poniżej zobaczysz, że wszystkie wywołania tych funkcji używają funkcji await .
Pod koniec tego artykułu ponownie będziesz miał projekt, z którym możesz poeksperymentować. Będzie to doskonały moment, aby spróbować dodać różnego rodzaju błędy w różnych miejscach w kodzie i zobaczyć, co dzieje się podczas kompilacji i testowania. W szczególności błędy w kontekście async mogą nie zachowywać się zgodnie z oczekiwaniami. Warto zagłębić się w różne rozwiązania, które wykraczają poza ramy tego artykułu.

Teraz, mając gotowe nasze DAO i usługi, wróćmy do kontrolera użytkownika.
Budowanie naszego kontrolera REST API
Jak powiedzieliśmy powyżej, ideą stojącą za kontrolerami jest oddzielenie konfiguracji trasy od kodu, który ostatecznie przetwarza żądanie trasy. Oznacza to, że wszystkie walidacje powinny zostać wykonane, zanim nasze żądanie dotrze do kontrolera. Kontroler musi tylko wiedzieć, co zrobić z rzeczywistym żądaniem, ponieważ jeśli żądanie dotarło tak daleko, to wiemy, że okazało się prawidłowe. Administrator będzie wtedy wywoływał odpowiednią usługę dla każdego żądania, które będzie obsługiwał.
Zanim zaczniemy, musimy zainstalować bibliotekę do bezpiecznego haszowania hasła użytkownika:
npm i argon2 Zacznijmy od utworzenia folderu o nazwie controllers w folderze kontrolera users i utworzenia w nim pliku o nazwie users.controller.ts :
// we import express to add types to the request/response objects from our controller functions import express from 'express'; // we import our newly created user services import usersService from '../services/users.service'; // we import the argon2 library for password hashing import argon2 from 'argon2'; // we use debug with a custom context as described in Part 1 import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersController { async listUsers(req: express.Request, res: express.Response) { const users = await usersService.list(100, 0); res.status(200).send(users); } async getUserById(req: express.Request, res: express.Response) { const user = await usersService.readById(req.body.id); res.status(200).send(user); } async createUser(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); const userId = await usersService.create(req.body); res.status(201).send({ id: userId }); } async patch(req: express.Request, res: express.Response) { if (req.body.password) { req.body.password = await argon2.hash(req.body.password); } log(await usersService.patchById(req.body.id, req.body)); res.status(204).send(); } async put(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); log(await usersService.putById(req.body.id, req.body)); res.status(204).send(); } async removeUser(req: express.Request, res: express.Response) { log(await usersService.deleteById(req.body.id)); res.status(204).send(); } } export default new UsersController(); Uwaga: powyższe wiersze nie odsyłają niczego z odpowiedzią HTTP 204 No Content , co jest zgodne z RFC 7231 w tym temacie.
Po zakończeniu naszego kontrolera użytkownika singleton jesteśmy gotowi do zakodowania drugiego modułu, który zależy od naszego przykładowego modelu obiektu i usługi REST API: naszego oprogramowania pośredniczącego użytkownika.
Oprogramowanie pośredniczące REST Node.js z Express.js
Co możemy zrobić z oprogramowaniem pośredniczącym Express.js? Po pierwsze, walidacje świetnie pasują. Dodajmy kilka podstawowych walidacji, aby działały jako strażnicy dla żądań, zanim dotrą do naszego kontrolera użytkownika:
- Zapewnij obecność pól użytkownika, takich jak adres e-
emailipassword, wymaganych do utworzenia lub aktualizacji użytkownika - Upewnij się, że dany adres e-mail nie jest już używany
- Sprawdź, czy nie zmieniamy pola
emailpo utworzeniu (ponieważ dla uproszczenia używamy go jako głównego identyfikatora widocznego dla użytkownika) - Sprawdź, czy dany użytkownik istnieje
Aby te walidacje działały z Express.js, musimy przetłumaczyć je na funkcje zgodne ze wzorcem Express.js kontroli przepływu przy użyciu next() , jak opisano w poprzednim artykule. Potrzebujemy nowego pliku users/middleware/users.middleware.ts :
import express from 'express'; import userService from '../services/users.service'; import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersMiddleware { } export default new UsersMiddleware();Pozbywając się znanego singletona, dodajmy niektóre z naszych funkcji oprogramowania pośredniego do treści klasy:
async validateRequiredUserBodyFields( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.email && req.body.password) { next(); } else { res.status(400).send({ error: `Missing required fields email and password`, }); } } async validateSameEmailDoesntExist( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user) { res.status(400).send({ error: `User email already exists` }); } else { next(); } } async validateSameEmailBelongToSameUser( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user && user.id === req.params.userId) { next(); } else { res.status(400).send({ error: `Invalid email` }); } } // Here we need to use an arrow function to bind `this` correctly validatePatchEmail = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { if (req.body.email) { log('Validating email', req.body.email); this.validateSameEmailBelongToSameUser(req, res, next); } else { next(); } }; async validateUserExists( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.readById(req.params.userId); if (user) { next(); } else { res.status(404).send({ error: `User ${req.params.userId} not found`, }); } } Aby ułatwić naszym konsumentom interfejsu API wysyłanie dalszych żądań dotyczących nowo dodanego użytkownika, dodamy funkcję pomocniczą, która wyodrębni userId użytkownika z parametrów żądania — pochodzący z samego adresu URL żądania — i doda go do treść żądania, w której znajdują się pozostałe dane użytkownika.
Pomysł polega na tym, aby móc po prostu użyć pełnego żądania treści, gdy chcemy zaktualizować informacje o użytkowniku, bez martwienia się o uzyskanie identyfikatora z parametrów za każdym razem. Zamiast tego zajmuje się nim tylko w jednym miejscu — w oprogramowaniu pośredniczącym. Funkcja będzie wyglądać tak:
async extractUserId( req: express.Request, res: express.Response, next: express.NextFunction ) { req.body.id = req.params.userId; next(); } Poza logiką, główna różnica między oprogramowaniem pośredniczącym a kontrolerem polega na tym, że teraz używamy funkcji next() do przekazywania kontroli wzdłuż łańcucha skonfigurowanych funkcji, aż dotrze ona do miejsca docelowego, którym w naszym przypadku jest kontroler.
Wszystko razem: refaktoryzacja naszych tras
Teraz, gdy zaimplementowaliśmy wszystkie nowe aspekty architektury naszego projektu, wróćmy do pliku users.routes.config.ts , który zdefiniowaliśmy w poprzednim artykule. Wywoła on nasze oprogramowanie pośredniczące i nasze kontrolery, z których oba opierają się na naszej obsłudze użytkownika, co z kolei wymaga naszego modelu użytkownika.
Ostateczny plik będzie tak prosty, jak ten:
import { CommonRoutesConfig } from '../common/common.routes.config'; import UsersController from './controllers/users.controller'; import UsersMiddleware from './middleware/users.middleware'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes(): express.Application { this.app .route(`/users`) .get(UsersController.listUsers) .post( UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailDoesntExist, UsersController.createUser ); this.app.param(`userId`, UsersMiddleware.extractUserId); this.app .route(`/users/:userId`) .all(UsersMiddleware.validateUserExists) .get(UsersController.getUserById) .delete(UsersController.removeUser); this.app.put(`/users/:userId`, [ UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailBelongToSameUser, UsersController.put, ]); this.app.patch(`/users/:userId`, [ UsersMiddleware.validatePatchEmail, UsersController.patch, ]); return this.app; } } W tym miejscu przedefiniowaliśmy nasze trasy, dodając oprogramowanie pośredniczące w celu sprawdzenia poprawności naszej logiki biznesowej i odpowiednich funkcji kontrolera do przetwarzania żądania, jeśli wszystko jest prawidłowe. Użyliśmy również funkcji .param() z userId do wyodrębnienia identyfikatora użytkownika .
W funkcji .all() przekazujemy naszą funkcję validateUserExists z UsersMiddleware , aby została wywołana, zanim jakikolwiek GET , PUT , PATCH lub DELETE będzie mógł przejść przez punkt końcowy /users/:userId . Oznacza to, że validateUserExists nie musi znajdować się w dodatkowych tablicach funkcji, które przekazujemy do .put() lub .patch() — zostanie wywołana przed określonymi tam funkcjami.
Wykorzystaliśmy również nieodłączną możliwość ponownego wykorzystania oprogramowania pośredniczącego w inny sposób. Przekazując UsersMiddleware.validateRequiredUserBodyFields do użycia zarówno w kontekście POST , jak i PUT , elegancko łączymy je z innymi funkcjami oprogramowania pośredniczącego.
Zastrzeżenia: W tym artykule omówimy tylko podstawowe walidacje. W prawdziwym projekcie będziesz musiał pomyśleć i znaleźć wszystkie ograniczenia, których potrzebujesz do kodowania. Dla uproszczenia zakładamy również, że użytkownik nie może zmienić swojego adresu e-mail.
Testowanie naszego interfejsu API REST Express/TypeScript
Możemy teraz skompilować i uruchomić naszą aplikację Node.js. Po uruchomieniu jesteśmy gotowi do testowania naszych tras API przy użyciu klienta REST, takiego jak Postman lub cURL.
Spróbujmy najpierw zdobyć naszych użytkowników:
curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'W tym momencie jako odpowiedź otrzymamy pustą tablicę, co jest dokładne. Teraz możemy spróbować stworzyć pierwszy zasób użytkownika za pomocą tego:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json'Zauważ, że teraz nasza aplikacja Node.js zwróci błąd z naszego oprogramowania pośredniczącego:
{ "error": "Missing required fields email and password" } Aby to naprawić, wyślijmy prawidłowe żądanie postu do zasobu /users :
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23" }'Tym razem powinniśmy zobaczyć coś takiego:
{ "id": "ksVnfnPVW" } Ten id jest identyfikatorem nowo utworzonego użytkownika i będzie inny na twoim komputerze. Aby ułatwić pozostałe instrukcje testowania, możesz uruchomić to polecenie za pomocą tego, które otrzymasz (zakładając, że używasz środowiska podobnego do Linuksa):
REST_API_EXAMPLE_ Możemy teraz zobaczyć odpowiedź, jaką otrzymujemy po wykonaniu żądania GET przy użyciu powyższej zmiennej:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' Możemy teraz również zaktualizować cały zasób za pomocą następującego żądania PUT :
curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23", "firstName": "Marcos", "lastName": "Silva", "permissionLevel": 8 }'Możemy również sprawdzić, czy nasza walidacja działa, zmieniając adres e-mail, co powinno skutkować błędem.
Zauważ, że używając PUT do identyfikatora zasobu, jako konsumenci API, musimy wysłać cały obiekt, jeśli chcemy zachować zgodność ze standardowym wzorcem REST. Oznacza to, że jeśli będziemy chcieli zaktualizować tylko pole lastName , ale korzystając z naszego punktu końcowego PUT , będziemy zmuszeni wysłać cały obiekt do aktualizacji. Łatwiej byłoby użyć żądania PATCH , ponieważ nadal mieści się w standardowych ograniczeniach REST, aby wysłać tylko pole lastName :
curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "lastName": "Faraco" }' Przypomnijmy, że w naszej własnej bazie kodu to nasza konfiguracja trasy wymusza to rozróżnienie między PUT i PATCH przy użyciu funkcji oprogramowania pośredniczącego, które dodaliśmy w tym artykule.
PUT , PATCH , czy oba?
Może się wydawać, że nie ma zbyt wiele powodów do obsługi PUT , biorąc pod uwagę elastyczność PATCH , a niektóre interfejsy API przyjmą to podejście. Inni mogą nalegać na wsparcie PUT , aby interfejs API był „całkowicie zgodny z REST”, w którym to przypadku tworzenie tras PUT dla poszczególnych pól może być odpowiednią taktyką dla typowych przypadków użycia.
W rzeczywistości te punkty są częścią znacznie szerszej dyskusji, począwszy od rzeczywistych różnic między tymi dwoma, a skończywszy na bardziej elastycznej semantyce samego PATCH . Przedstawiamy tutaj wsparcie PUT i szeroko stosowaną semantykę PATCH dla uproszczenia, ale zachęcamy czytelników do zagłębienia się w dalsze badania, gdy czują się na to gotowi.
Pobierając ponownie listę użytkowników, tak jak to zrobiliśmy powyżej, powinniśmy zobaczyć naszego utworzonego użytkownika ze zaktualizowanymi polami:
[ { "id": "ksVnfnPVW", "email": "[email protected]", "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM", "firstName": "Marcos", "lastName": "Faraco", "permissionLevel": 8 } ]Na koniec możemy przetestować usuwanie użytkownika za pomocą tego:
curl --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'Pobierając ponownie listę użytkowników, powinniśmy zobaczyć, że usunięty użytkownik nie jest już obecny.
Dzięki temu wszystkie operacje CRUD dla zasobu users działają.
Node.js/TypeScript REST API
W tej części serii dokładniej zbadaliśmy kluczowe kroki w tworzeniu interfejsu API REST przy użyciu Express.js. Dzielimy nasz kod na usługi wsparcia, oprogramowanie pośredniczące, kontrolery i modele. Każda z ich funkcji ma określoną rolę, niezależnie od tego, czy jest to walidacja, operacje logiczne, czy przetwarzanie poprawnych żądań i odpowiadanie na nie.
Stworzyliśmy również bardzo prosty sposób przechowywania danych, z (przepraszam za kalambur) wyraźnym celem umożliwienia pewnych testów w tym momencie, a następnie zastąpienia czegoś bardziej praktycznego w następnej części naszej serii.
Oprócz tworzenia interfejsu API z myślą o prostocie — na przykład przy użyciu klas pojedynczych — należy wykonać kilka kroków, aby ułatwić jego utrzymanie, zwiększyć skalowalność i bezpieczeństwo. W ostatnim artykule z tej serii omówimy:
- Zastąpienie bazy danych w pamięci MongoDB, a następnie użycie Mongoose w celu uproszczenia procesu kodowania
- Dodanie warstwy bezpieczeństwa i kontrola dostępu w podejściu bezstanowym za pomocą JWT
- Konfigurowanie testów automatycznych, aby umożliwić skalowanie naszej aplikacji
Możesz przejrzeć ostateczny kod z tego artykułu tutaj.
