Crearea unui API REST Node.js/TypeScript, partea 1: Express.js

Publicat: 2022-03-11

Cum scriu un API REST în Node.js?

Când construiți un back-end pentru un API REST, Express.js este adesea prima alegere dintre cadrele Node.js. Deși acceptă și construirea de HTML static și șabloane, în această serie, ne vom concentra pe dezvoltarea back-end folosind TypeScript. API-ul REST rezultat va fi unul pe care orice cadru front-end sau serviciu back-end extern l-ar putea interoga.

O să ai nevoie:

  • Cunoștințe de bază JavaScript și TypeScript
  • Cunoștințe de bază despre Node.js
  • Cunoștințe de bază despre arhitectura REST (consultați această secțiune a articolului meu anterior REST API, dacă este necesar)
  • O instalare gata de Node.js (de preferință versiunea 14+)

Într-un terminal (sau prompt de comandă), vom crea un folder pentru proiect. Din acel folder, rulați npm init . Aceasta va crea unele dintre fișierele de proiect de bază Node.js de care avem nevoie.

În continuare, vom adăuga cadrul Express.js și câteva biblioteci utile:

 npm i express debug winston express-winston cors

Există motive întemeiate pentru care aceste biblioteci sunt favoritele dezvoltatorilor Node.js:

  • debug este un modul pe care îl vom folosi pentru a evita apelarea console.log() în timpul dezvoltării aplicației noastre. În acest fel, putem filtra cu ușurință declarațiile de depanare în timpul depanării. De asemenea, pot fi oprite complet în producție, în loc să fie eliminate manual.
  • winston este responsabil pentru înregistrarea cererilor în API-ul nostru și a răspunsurilor (și erorilor) returnate. express-winston se integrează direct cu Express.js, astfel încât tot codul standard de înregistrare winston legat de API este deja realizat.
  • cors este o bucată de middleware Express.js care ne permite să activăm partajarea resurselor între origini. Fără aceasta, API-ul nostru ar putea fi utilizat numai de la front-end-uri care sunt servite exact din același subdomeniu ca și back-end-ul nostru.

Back-end-ul nostru folosește aceste pachete atunci când rulează. Dar trebuie să instalăm și unele dependențe de dezvoltare pentru configurația noastră TypeScript. Pentru asta, vom rula:

 npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

Aceste dependențe sunt necesare pentru a activa TypeScript pentru propriul cod al aplicației noastre, împreună cu tipurile utilizate de Express.js și alte dependențe. Acest lucru poate economisi mult timp atunci când folosim un IDE precum WebStorm sau VSCode, permițându-ne să finalizăm automat anumite metode de funcționare în timpul codificării.

Dependențele finale din package.json ar trebui să fie astfel:

 "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }

Acum că avem toate dependențele necesare instalate, să începem să construim propriul cod!

Structura proiectului API REST TypeScript

Pentru acest tutorial, vom crea doar trei fișiere:

  1. ./app.ts
  2. ./common/common.routes.config.ts
  3. ./users/users.routes.config.ts

Ideea din spatele celor două foldere ale structurii proiectului ( common și users ) este de a avea module individuale care au propriile responsabilități. În acest sens, în cele din urmă vom avea unele sau toate următoarele pentru fiecare modul:

  • Configurarea rutei pentru a defini solicitările pe care le poate gestiona API-ul nostru
  • Servicii pentru sarcini precum conectarea la modelele noastre de baze de date, efectuarea de interogări sau conectarea la servicii externe solicitate de cererea specifică
  • Middleware pentru rularea unor validări de cereri specifice înainte ca controlerul final al unei rute să se ocupe de specificul acesteia
  • Modele pentru definirea modelelor de date care se potrivesc cu o anumită schemă de bază de date, pentru a facilita stocarea și recuperarea datelor
  • Controlere pentru separarea configurației rutei de codul care în cele din urmă (după orice middleware) procesează o solicitare de rută, apelează funcțiile de serviciu de mai sus dacă este necesar și oferă un răspuns clientului

Această structură de foldere oferă un design de bază REST API, un punct de pornire timpuriu pentru restul acestei serii de tutoriale și suficient pentru a începe exersarea.

Un fișier de rute comune în TypeScript

În folderul common , să creăm fișierul common.routes.config.ts care să arate astfel:

 import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }

Modul în care creăm rutele aici este opțional. Dar, deoarece lucrăm cu TypeScript, scenariul nostru de rute este o oportunitate de a exersa utilizarea moștenirii cu cuvântul cheie extends , așa cum vom vedea în curând. În acest proiect, toate fișierele de rută au același comportament: au un nume (pe care îl vom folosi în scopuri de depanare) și acces la obiectul principal de Application Express.js.

Acum, putem începe să creăm fișierul de rută al utilizatorilor. În folderul users , să creăm users.routes.config.ts și să începem să-l codificăm astfel:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }

Aici, importăm clasa CommonRoutesConfig și o extindem la noua noastră clasă, numită UsersRoutes . Cu constructorul, trimitem aplicația (obiectul principal express.Application) și numele express.Application către constructorul lui CommonRoutesConfig .

Acest exemplu este destul de simplu, dar atunci când scalați pentru a crea mai multe fișiere de rută, acest lucru ne va ajuta să evităm codul duplicat.

Să presupunem că am dori să adăugăm noi caracteristici în acest fișier, cum ar fi înregistrarea în jurnal. Am putea adăuga câmpul necesar la clasa CommonRoutesConfig , iar apoi toate rutele care extind CommonRoutesConfig vor avea acces la el.

Utilizarea funcțiilor abstracte TypeScript pentru funcționalități similare între clase

Ce se întâmplă dacă am dori să avem o funcționalitate similară între aceste clase (cum ar fi configurarea punctelor finale API), dar care necesită o implementare diferită pentru fiecare clasă? O opțiune este să utilizați o caracteristică TypeScript numită abstractizare .

Să creăm o funcție abstractă foarte simplă pe care clasa UsersRoutes (și viitoarele clase de rutare) o va moșteni din CommonRoutesConfig . Să presupunem că vrem să forțăm toate rutele să aibă o funcție (deci să o putem apela de la constructorul nostru comun) numită configureRoutes() . Acolo vom declara punctele finale ale fiecărei resurse ale clasei de rutare.

Pentru a face acest lucru, vom adăuga trei lucruri rapide la common.routes.config.ts :

  1. Cuvântul cheie abstract la linia noastră class , pentru a permite abstracția pentru această clasă.
  2. O nouă declarație de funcție la sfârșitul clasei noastre, abstract configureRoutes(): express.Application; . Acest lucru forțează orice clasă care extinde CommonRoutesConfig să furnizeze o implementare care să se potrivească cu semnătura respectivă - dacă nu o face, compilatorul TypeScript va arunca o eroare.
  3. Un apel la this.configureRoutes(); la sfârșitul constructorului, deoarece acum putem fi siguri că această funcție va exista.

Rezultatul:

 import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }

Cu asta, orice clasă care se extinde CommonRoutesConfig trebuie să aibă o funcție numită configureRoutes() care returnează un obiect express.Application . Asta înseamnă că users.routes.config.ts are nevoie de actualizare:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }

Ca o recapitulare a ceea ce am făcut:

Mai întâi importăm fișierul common.routes.config , apoi modulul express . Apoi definim clasa UserRoutes , spunând că vrem ca aceasta să extindă clasa de bază CommonRoutesConfig , ceea ce implică că promitem că va implementa configureRoutes() .

Pentru a trimite informații în clasa CommonRoutesConfig , folosim constructor clasei. Se așteaptă să primească obiectul express.Application , pe care îl vom descrie mai în profunzime în pasul următor. Cu super() , transmitem constructorului lui CommonRoutesConfig aplicația și numele rutelor noastre, care în acest scenariu este UsersRoutes. ( super() , la rândul său, va apela implementarea noastră a configureRoutes() .)

Configurarea rutelor Express.js ale punctelor finale ale utilizatorilor

Funcția configureRoutes() este locul în care vom crea punctele finale pentru utilizatorii API-ului nostru REST. Acolo, vom folosi aplicația și funcționalitățile sale de rută din Express.js.

Ideea în utilizarea funcției app.route() este de a evita duplicarea codului, ceea ce este ușor, deoarece creăm un API REST cu resurse bine definite. Principala resursă pentru acest tutorial sunt utilizatorii . Avem două cazuri în acest scenariu:

  • Când apelantul API dorește să creeze un utilizator nou sau să enumere toți utilizatorii existenți, punctul final ar trebui să aibă inițial doar users la sfârșitul căii solicitate. (Nu vom intra în filtrarea interogărilor, paginarea sau alte asemenea interogări în acest articol.)
  • Când apelantul dorește să facă ceva specific unei anumite înregistrări de utilizator, calea resursei cererii va urma modelul users/:userId .

Modul în care .route() în Express.js ne permite să gestionăm verbele HTTP cu o înlănțuire elegantă. Acest lucru se datorează faptului că .get() , .post() etc., toate returnează aceeași instanță a IRoute pe care o face primul apel .route() . Configurația finală va fi astfel:

 configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }

Codul de mai sus permite oricărui client API REST să apeleze punctul final al users noștri cu o solicitare POST sau GET . În mod similar, permite unui client să apeleze punctul nostru final /users/:userId cu o solicitare GET , PUT , PATCH sau DELETE .

Dar pentru /users/:userId , am adăugat, de asemenea, middleware generic folosind funcția all() , care va fi rulată înaintea oricărei funcții get() , put() , patch() sau delete() . Această funcție va fi benefică atunci când (mai târziu în serie) vom crea rute care sunt menite să fie accesate doar de utilizatorii autentificați.

S-ar putea să fi observat că în funcția noastră .all() - ca și în cazul oricărei piese de middleware - avem trei tipuri de câmpuri: Request , Response și NextFunction .

  • Solicitarea este modul în care Express.js reprezintă cererea HTTP care trebuie gestionată. Acest tip actualizează și extinde tipul de cerere nativ Node.js.
  • Răspunsul este, de asemenea, modul în care Express.js reprezintă răspunsul HTTP, extinzând din nou tipul de răspuns nativ Node.js.
  • Nu mai puțin important, NextFunction servește ca o funcție de apel invers, permițând controlului să treacă prin orice alte funcții middleware. Pe parcurs, toate middleware-urile vor partaja aceleași obiecte de cerere și răspuns înainte ca controlerul să trimită în sfârșit un răspuns către solicitant.

Fișierul nostru de intrare Node.js, app.ts

Acum că am configurat câteva schelete de rută de bază, vom începe configurarea punctului de intrare al aplicației. Să creăm fișierul app.ts la rădăcina dosarului nostru de proiect și să începem cu acest cod:

 import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';

Doar două dintre aceste importuri sunt noi în acest moment al articolului:

  • http este un modul nativ Node.js. Este necesar să porniți aplicația noastră Express.js.
  • body-parser este un middleware care vine cu Express.js. Analizează cererea (în cazul nostru, ca JSON) înainte ca controlul să treacă la propriii noștri gestionatori de cereri.

Acum că am importat fișierele, vom începe să declarăm variabilele pe care dorim să le folosim:

 const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');

Funcția express() returnează obiectul principal al aplicației Express.js pe care îl vom transmite prin codul nostru, începând cu adăugarea lui la obiectul http.Server . (Va trebui să pornim http.Server după configurarea aplicației noastre express.Application .)

Vom asculta pe portul 3000 - despre care TypeScript va deduce automat că este un Number - în loc de porturile standard 80 (HTTP) sau 443 (HTTPS), deoarece acestea ar fi utilizate de obicei pentru front-end-ul unei aplicații.

De ce Port 3000?

Nu există nicio regulă conform căreia portul ar trebui să fie 3000 – dacă nu este specificat, va fi atribuit un port arbitrar – dar 3000 este folosit în exemplele de documentație atât pentru Node.js, cât și pentru Express.js, așa că continuăm tradiția aici.

Poate Node.js să partajeze porturi cu front-end-ul?

Putem rula în continuare local la un port personalizat, chiar și atunci când dorim ca back-end-ul nostru să răspundă la solicitările pe porturile standard. Acest lucru ar necesita un proxy invers pentru a primi cereri pe portul 80 sau 443 cu un anumit domeniu sau un subdomeniu. Apoi le-ar redirecționa către portul nostru intern 3000.

Matricea de routes va ține evidența fișierelor noastre de rute în scopuri de depanare, așa cum vom vedea mai jos.

În cele din urmă, debugLog va ajunge ca o funcție similară cu console.log , dar mai bună: este mai ușor de ajustat, deoarece este încadrat automat la orice dorim să numim contextul fișierului/modulului. (În acest caz, am numit-o „aplicație” când am trecut asta într-un șir constructorului debug() .)

Acum, suntem gata să configuram toate modulele noastre middleware Express.js și rutele API-ului nostru:

 // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });

expressWinston.logger se conectează la Express.js, înregistrând automat detaliile - prin aceeași infrastructură ca și debug - pentru fiecare solicitare finalizată. Opțiunile pe care le-am transmis vor formata și colora cu atenție ieșirea terminalului corespunzătoare, cu o înregistrare mai detaliată (implicit) când suntem în modul de depanare.

Rețineți că trebuie să ne definim rutele după ce am configurat expressWinston.logger .

În sfârșit și cel mai important:

 server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });

Acest lucru pornește de fapt serverul nostru. Odată pornit, Node.js va rula funcția noastră de apel invers, care în modul de depanare raportează numele tuturor rutelor pe care le-am configurat – până acum, doar UsersRoutes . După aceea, apelul nostru ne anunță că back-end-ul nostru este gata să primească solicitări, chiar și atunci când rulează în modul producție.

Se actualizează package.json la Transpile TypeScript în JavaScript și rulează aplicația

Acum că avem scheletul gata de rulare, mai întâi avem nevoie de o configurație standard pentru a activa transpilarea TypeScript. Să adăugăm fișierul tsconfig.json în rădăcina proiectului:

 { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }

Apoi trebuie doar să adăugăm ultimele retușuri la package.json sub forma următoarelor scripturi:

 "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },

Scriptul de test este un substituent pe care îl vom înlocui mai târziu în serie.

tsc din scriptul de start aparține TypeScript. Este responsabil pentru transpilarea codului nostru TypeScript în JavaScript, pe care îl va scoate în folderul dist . Apoi, rulăm versiunea construită cu node ./dist/app.js .

Transmitem --unhandled-rejections=strict la Node.js (chiar și cu Node.js v16+), deoarece, în practică, depanarea folosind o abordare directă „crash and show the stack” este mai simplă decât înregistrarea mai simplă cu un obiect expressWinston.errorLogger . Acest lucru este cel mai adesea adevărat chiar și în producție, unde lăsarea Node.js să continue să ruleze în ciuda unei respingeri necontrolate este probabil să lase serverul într-o stare neașteptată, permițând să apară erori suplimentare (și mai complicate).

Scriptul de debug apelează scriptul de start , dar mai întâi definește o variabilă de mediu DEBUG . Acest lucru are ca efect activarea tuturor instrucțiunilor noastre debugLog() (plus altele similare din Express.js în sine, care utilizează același modul de debug pe care îl facem noi) pentru a scoate detalii utile către terminal - detalii care sunt (în mod convenabil) ascunse atunci când rulăm serverul în modul producție cu o npm start standard.

Încercați să rulați singur npm run debug și, apoi, comparați-l cu npm start pentru a vedea cum se schimbă ieșirea consolei.

Sfat: puteți limita rezultatul de depanare la propriile instrucțiuni app.ts debugLog() ale fișierului app.ts folosind DEBUG=app în loc de DEBUG=* . Modulul de debug este în general destul de flexibil, iar această caracteristică nu face excepție.

Utilizatorii Windows vor trebui probabil să schimbe export în SET , deoarece export este modul în care funcționează pe Mac și Linux. Dacă proiectul dvs. trebuie să suporte mai multe medii de dezvoltare, pachetul cross-env oferă aici o soluție simplă.

Testarea back-end-ului Live Express.js

Cu npm run debug sau npm start încă în desfășurare, API-ul nostru REST va fi gata să deservească cererile pe portul 3000. În acest moment, putem folosi cURL, Postman, Insomnia etc. pentru a testa back-end-ul.

Deoarece am creat doar un schelet pentru resursa utilizatorilor, putem pur și simplu trimite cereri fără un corp pentru a vedea că totul funcționează conform așteptărilor. De exemplu:

 curl --request GET 'localhost:3000/users/12345'

Backend-ul nostru ar trebui să trimită înapoi răspunsul GET requested for id 12345 .

Cât despre POST ING:

 curl --request POST 'localhost:3000/users' \ --data-raw ''

Aceasta și toate celelalte tipuri de solicitări pentru care am construit scheletele vor arăta destul de asemănătoare.

Pregătit pentru dezvoltarea rapidă a API-ului REST Node.js cu TypeScript

În acest articol, am început să creăm un API REST configurând proiectul de la zero și aruncându-ne în elementele de bază ale cadrului Express.js. Apoi, am făcut primul pas către stăpânirea TypeScript prin construirea unui model cu UsersRoutesConfig extinzând CommonRoutesConfig , un model pe care îl vom reutiliza pentru următorul articol din această serie. Am terminat prin configurarea punctului de intrare app.ts pentru a folosi noile noastre rute și package.json cu scripturi pentru a construi și rula aplicația noastră.

Dar chiar și elementele de bază ale unui API REST realizat cu Express.js și TypeScript sunt destul de implicate. În următoarea parte a acestei serii, ne concentrăm pe crearea de controlere adecvate pentru resursa utilizatorilor și să analizăm câteva modele utile pentru servicii, middleware, controlere și modele.

Proiectul complet este disponibil pe GitHub, iar codul de la sfârșitul acestui articol se găsește în toptal-article-01 .