Creazione di un'API REST Node.js/TypeScript, parte 1: Express.js

Pubblicato: 2022-03-11

Come posso scrivere un'API REST in Node.js?

Quando si crea un back-end per un'API REST, Express.js è spesso la prima scelta tra i framework Node.js. Sebbene supporti anche la creazione di modelli e HTML statici, in questa serie ci concentreremo sullo sviluppo di back-end utilizzando TypeScript. L'API REST risultante sarà quella che qualsiasi framework front-end o servizio back-end esterno sarebbe in grado di eseguire query.

Avrai bisogno:

  • Conoscenza di base di JavaScript e TypeScript
  • Conoscenza di base di Node.js
  • Conoscenza di base dell'architettura REST (cfr. questa sezione del mio precedente articolo sull'API REST, se necessario)
  • Una pronta installazione di Node.js (preferibilmente versione 14+)

In un terminale (o prompt dei comandi), creeremo una cartella per il progetto. Da quella cartella, esegui npm init . Ciò creerà alcuni dei file di progetto Node.js di base di cui abbiamo bisogno.

Successivamente, aggiungeremo il framework Express.js e alcune utili librerie:

 npm i express debug winston express-winston cors

Ci sono buone ragioni per cui queste librerie sono le preferite dagli sviluppatori di Node.js:

  • debug è un modulo che useremo per evitare di chiamare console.log() durante lo sviluppo della nostra applicazione. In questo modo, possiamo filtrare facilmente le istruzioni di debug durante la risoluzione dei problemi. Possono anche essere spenti completamente in produzione invece di dover essere rimossi manualmente.
  • winston è responsabile della registrazione delle richieste nella nostra API e delle risposte (e degli errori) restituite. express-winston si integra direttamente con Express.js, in modo che tutto il codice di registrazione winston relativo alle API standard sia già eseguito.
  • cors è un componente del middleware Express.js che ci consente di abilitare la condivisione di risorse tra origini. Senza questo, la nostra API sarebbe utilizzabile solo dal front-end servito dallo stesso identico sottodominio del nostro back-end.

Il nostro back-end utilizza questi pacchetti quando è in esecuzione. Ma abbiamo anche bisogno di installare alcune dipendenze di sviluppo per la nostra configurazione TypeScript. Per questo, eseguiremo:

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

Queste dipendenze sono necessarie per abilitare TypeScript per il codice della nostra app, insieme ai tipi usati da Express.js e altre dipendenze. Questo può far risparmiare molto tempo quando utilizziamo un IDE come WebStorm o VSCode, consentendoci di completare automaticamente alcuni metodi di funzione durante la codifica.

Le dipendenze finali in package.json dovrebbero essere così:

 "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" }

Ora che abbiamo installato tutte le nostre dipendenze richieste, iniziamo a creare il nostro codice!

Struttura del progetto dell'API REST TypeScript

Per questo tutorial, creeremo solo tre file:

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

L'idea alla base delle due cartelle della struttura del progetto ( common e users ) è quella di avere moduli individuali che hanno le proprie responsabilità. In questo senso, alla fine avremo alcuni o tutti i seguenti elementi per ciascun modulo:

  • Configurazione del percorso per definire le richieste che la nostra API può gestire
  • Servizi per attività come la connessione ai nostri modelli di database, l'esecuzione di query o la connessione a servizi esterni richiesti dalla richiesta specifica
  • Middleware per eseguire convalide di richieste specifiche prima che il controller finale di un percorso gestisca le sue specifiche
  • Modelli per la definizione di modelli di dati corrispondenti a un determinato schema di database, per facilitare l'archiviazione e il recupero dei dati
  • Controller per separare la configurazione del percorso dal codice che alla fine (dopo qualsiasi middleware) elabora una richiesta di percorso, chiama le funzioni di servizio di cui sopra se necessario e fornisce una risposta al client

Questa struttura di cartelle fornisce una progettazione di base dell'API REST, un punto di partenza iniziale per il resto di questa serie di tutorial e abbastanza per iniziare a esercitarsi.

Un file di percorsi comuni in TypeScript

Nella cartella common , creiamo il file common.routes.config.ts in modo che assomigli al seguente:

 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; } }

Il modo in cui stiamo creando i percorsi qui è facoltativo. Ma dal momento che stiamo lavorando con TypeScript, il nostro scenario di route è un'opportunità per esercitarsi nell'uso dell'ereditarietà con la parola chiave extends , come vedremo tra breve. In questo progetto, tutti i file di route hanno lo stesso comportamento: hanno un nome (che useremo per scopi di debug) e accesso all'oggetto principale Application Express.js.

Ora possiamo iniziare a creare il file di percorso degli utenti. Nella cartella degli users , creiamo users.routes.config.ts e iniziamo a codificarlo in questo modo:

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

Qui importiamo la classe CommonRoutesConfig e la estendiamo alla nostra nuova classe, denominata UsersRoutes . Con il costruttore, inviamo l'app (l'oggetto express.Application principale) e il nome UsersRoutes al costruttore di CommonRoutesConfig .

Questo esempio è abbastanza semplice, ma quando si ridimensiona per creare più file di percorso, questo ci aiuterà a evitare la duplicazione del codice.

Supponiamo di voler aggiungere nuove funzionalità a questo file, come la registrazione. Potremmo aggiungere il campo necessario alla classe CommonRoutesConfig e quindi tutte le rotte che estendono CommonRoutesConfig avranno accesso ad esso.

Utilizzo delle funzioni astratte di TypeScript per funzionalità simili in tutte le classi

E se vorremmo avere alcune funzionalità simili tra queste classi (come la configurazione degli endpoint API), ma che richiedono un'implementazione diversa per ciascuna classe? Un'opzione consiste nell'usare una funzione TypeScript chiamata astrazione .

Creiamo una funzione astratta molto semplice che la classe UsersRoutes (e le future classi di routing) erediterà da CommonRoutesConfig . Diciamo che vogliamo forzare tutte le rotte ad avere una funzione (in modo che possiamo chiamarla dal nostro costruttore comune) chiamata configureRoutes() . È qui che dichiareremo gli endpoint di ciascuna risorsa di classe di routing.

Per fare ciò, aggiungeremo tre cose rapide a common.routes.config.ts :

  1. La parola chiave abstract alla nostra linea di class , per abilitare l'astrazione per questa classe.
  2. Una nuova dichiarazione di funzione alla fine della nostra classe, abstract configureRoutes(): express.Application; . Ciò obbliga qualsiasi classe che estende CommonRoutesConfig a fornire un'implementazione che corrisponda a quella firma; in caso contrario, il compilatore TypeScript genererà un errore.
  3. Una chiamata a this.configureRoutes(); alla fine del costruttore, poiché ora possiamo essere sicuri che questa funzione esisterà.

Il risultato:

 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; }

Con ciò, qualsiasi classe che estende CommonRoutesConfig deve avere una funzione chiamata configureRoutes() che restituisce un oggetto express.Application . Ciò significa che users.routes.config.ts deve essere aggiornato:

 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; } }

Riepilogando quello che abbiamo realizzato:

Stiamo prima importando il file common.routes.config , quindi il modulo express . Definiamo quindi la classe UserRoutes , dicendo che vogliamo che estenda la classe base CommonRoutesConfig , il che implica che promettiamo che implementerà configureRoutes() .

Per inviare informazioni alla classe CommonRoutesConfig , utilizziamo il constructor della classe. Si aspetta di ricevere l'oggetto express.Application , che descriveremo in modo più approfondito nel passaggio successivo. Con super() , passiamo al costruttore di CommonRoutesConfig l'applicazione e il nome dei nostri percorsi, che in questo scenario è UsersRoutes. ( super() , a sua volta, chiamerà la nostra implementazione di configureRoutes() .)

Configurazione dei percorsi Express.js degli endpoint degli utenti

La funzione configureRoutes() è dove creeremo gli endpoint per gli utenti della nostra API REST. Lì utilizzeremo l'applicazione e le sue funzionalità di route da Express.js.

L'idea nell'utilizzo della funzione app.route() è di evitare la duplicazione del codice, il che è facile poiché stiamo creando un'API REST con risorse ben definite. La risorsa principale per questo tutorial sono gli utenti . Abbiamo due casi in questo scenario:

  • Quando il chiamante dell'API desidera creare un nuovo utente o elencare tutti gli utenti esistenti, l'endpoint dovrebbe inizialmente avere solo users alla fine del percorso richiesto. (Non entreremo nel filtraggio delle query, nell'impaginazione o in altre query simili in questo articolo.)
  • Quando il chiamante vuole fare qualcosa di specifico per un record utente specifico, il percorso della risorsa della richiesta seguirà il modello users/:userId .

Il modo in cui .route() funziona in Express.js ci consente di gestire i verbi HTTP con un concatenamento elegante. Questo perché .get() , .post() , ecc., restituiscono tutti la stessa istanza di IRoute che fa la prima chiamata .route() . La configurazione finale sarà così:

 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; }

Il codice sopra consente a qualsiasi client API REST di chiamare l'endpoint users con una richiesta POST o GET . Allo stesso modo, consente a un client di chiamare il nostro /users/:userId endpoint con una richiesta GET , PUT , PATCH o DELETE .

Ma per /users/:userId , abbiamo anche aggiunto un middleware generico utilizzando la funzione all() , che verrà eseguita prima di qualsiasi funzione get() , put() , patch() o delete() . Questa funzione sarà utile quando (più avanti nella serie) creiamo percorsi a cui possono accedere solo utenti autenticati.

Potresti aver notato che nella nostra funzione .all() , come con qualsiasi altro middleware, abbiamo tre tipi di campi: Request , Response e NextFunction .

  • La richiesta è il modo in cui Express.js rappresenta la richiesta HTTP da gestire. Questo tipo aggiorna ed estende il tipo di richiesta Node.js nativo.
  • La risposta è anche il modo in cui Express.js rappresenta la risposta HTTP, estendendo ancora una volta il tipo di risposta nativo di Node.js.
  • Non meno importante, NextFunction funge da funzione di callback, consentendo al controllo di passare attraverso qualsiasi altra funzione middleware. Lungo il percorso, tutto il middleware condividerà gli stessi oggetti di richiesta e risposta prima che il controller invii finalmente una risposta al richiedente.

Il nostro file del punto di ingresso di Node.js, app.ts

Ora che abbiamo configurato alcuni scheletri di percorso di base, inizieremo a configurare il punto di ingresso dell'applicazione. Creiamo il file app.ts nella radice della nostra cartella del progetto e iniziamo con questo codice:

 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';

Solo due di queste importazioni sono nuove a questo punto dell'articolo:

  • http è un modulo nativo di Node.js. È necessario per avviare la nostra applicazione Express.js.
  • body-parser è un middleware fornito con Express.js. Analizza la richiesta (nel nostro caso, come JSON) prima che il controllo vada ai nostri gestori delle richieste.

Ora che abbiamo importato i file, inizieremo a dichiarare le variabili che vogliamo utilizzare:

 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');

La funzione express() restituisce l'oggetto dell'applicazione Express.js principale che passeremo in tutto il nostro codice, iniziando con l'aggiunta all'oggetto http.Server . (Dovremo avviare http.Server dopo aver configurato il nostro express.Application .)

Ascolteremo sulla porta 3000, che TypeScript dedurrà automaticamente è un Number , invece delle porte standard 80 (HTTP) o 443 (HTTPS), perché in genere vengono utilizzate per il front-end di un'app.

Perché Port 3000?

Non esiste una regola per cui la porta dovrebbe essere 3000, se non specificata, verrà assegnata una porta arbitraria, ma 3000 viene utilizzata negli esempi di documentazione sia per Node.js che per Express.js, quindi continuiamo la tradizione qui.

Node.js può condividere le porte con il front-end?

Possiamo ancora eseguire localmente su una porta personalizzata, anche quando vogliamo che il nostro back-end risponda alle richieste su porte standard. Ciò richiederebbe un proxy inverso per ricevere richieste sulla porta 80 o 443 con un dominio o un sottodominio specifico. Li reindirizzerebbe quindi alla nostra porta interna 3000.

L'array dei routes terrà traccia dei nostri file di percorsi a scopo di debug, come vedremo di seguito.

Infine, debugLog finirà come una funzione simile a console.log , ma migliore: è più facile da mettere a punto perché è automaticamente adattato a qualunque cosa vogliamo chiamare il nostro contesto di file/modulo. (In questo caso, l'abbiamo chiamata "app" quando l'abbiamo passata in una stringa al costruttore debug() .)

Ora siamo pronti per configurare tutti i nostri moduli middleware Express.js e i percorsi della nostra API:

 // 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 aggancia a Express.js, registrando automaticamente i dettagli, tramite la stessa infrastruttura del debug , per ogni richiesta completata. Le opzioni che abbiamo passato formatteranno e coloriranno in modo ordinato l'output del terminale corrispondente, con una registrazione più dettagliata (l'impostazione predefinita) quando siamo in modalità di debug.

Nota che dobbiamo definire i nostri percorsi dopo aver impostato expressWinston.logger .

Infine e soprattutto:

 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); });

Questo in realtà avvia il nostro server. Una volta avviato, Node.js eseguirà la nostra funzione di callback, che in modalità debug riporta i nomi di tutte le route che abbiamo configurato, finora solo UsersRoutes . Successivamente, il nostro callback ci informa che il nostro back-end è pronto a ricevere richieste, anche quando è in esecuzione in modalità produzione.

Aggiornamento di package.json in Transpile TypeScript in JavaScript ed eseguire l'app

Ora che abbiamo il nostro scheletro pronto per l'esecuzione, abbiamo prima bisogno di una configurazione standard per abilitare la traspilazione di TypeScript. Aggiungiamo il file tsconfig.json nella radice del progetto:

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

Quindi dobbiamo solo aggiungere gli ultimi ritocchi a package.json sotto forma dei seguenti script:

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

Lo script di test è un segnaposto che sostituiremo più avanti nella serie.

Il tsc nello script di start appartiene a TypeScript. È responsabile della traspilazione del nostro codice TypeScript in JavaScript, che verrà restituito nella cartella dist . Quindi, eseguiamo semplicemente la versione compilata con node ./dist/app.js .

Passiamo --unhandled-rejections=strict a Node.js (anche con Node.js v16+) perché in pratica, il debug utilizzando un approccio diretto "crash and show the stack" è più semplice della registrazione più elaborata con un oggetto expressWinston.errorLogger . Questo è spesso vero anche in produzione, dove è probabile che lasciare che Node.js continui a funzionare nonostante un rifiuto non gestito lasci il server in uno stato imprevisto, consentendo il verificarsi di ulteriori (e più complicati) bug.

Lo script di debug richiama lo script di start ma prima definisce una variabile di ambiente DEBUG . Questo ha l'effetto di abilitare tutte le nostre debugLog() (più quelle simili dallo stesso Express.js, che usa lo stesso modulo di debug che facciamo) per inviare dettagli utili al terminale, dettagli che sono (opportunamente) altrimenti nascosti durante l'esecuzione il server in modalità produzione con npm start standard.

Prova a eseguire tu stesso npm run debug e, successivamente, confrontalo con npm start per vedere come cambia l'output della console.

Suggerimento: puoi limitare l'output di debug alle istruzioni debugLog() del nostro file app.ts usando DEBUG=app invece di DEBUG=* . Il modulo di debug è generalmente abbastanza flessibile e questa funzionalità non fa eccezione.

Gli utenti Windows probabilmente dovranno modificare l' export in SET poiché export è il modo in cui funziona su Mac e Linux. Se il tuo progetto deve supportare più ambienti di sviluppo, il pacchetto cross-env fornisce una soluzione semplice qui.

Testare il back-end di Live Express.js

Con npm run debug o npm start ancora in corso, la nostra API REST sarà pronta per soddisfare le richieste sulla porta 3000. A questo punto, possiamo utilizzare cURL, Postman, Insomnia, ecc. per testare il back-end.

Poiché abbiamo creato solo uno scheletro per la risorsa degli utenti, possiamo semplicemente inviare richieste senza un corpo per vedere che tutto funziona come previsto. Per esempio:

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

Il nostro back-end dovrebbe restituire la risposta GET requested for id 12345 .

Per quanto riguarda POST ing:

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

Questo e tutti gli altri tipi di richieste per cui abbiamo creato gli scheletri sembreranno abbastanza simili.

In bilico per lo sviluppo rapido dell'API REST Node.js con TypeScript

In questo articolo, abbiamo iniziato a creare un'API REST configurando il progetto da zero e immergendoci nelle basi del framework Express.js. Quindi, abbiamo fatto il nostro primo passo verso la padronanza di TypeScript creando un modello con UsersRoutesConfig estendendo CommonRoutesConfig , un modello che riutilizzeremo per il prossimo articolo di questa serie. Abbiamo terminato configurando il nostro punto di ingresso app.ts per utilizzare i nostri nuovi percorsi e package.json con gli script per creare ed eseguire la nostra applicazione.

Ma anche le basi di un'API REST realizzata con Express.js e TypeScript sono abbastanza coinvolte. Nella parte successiva di questa serie, ci concentreremo sulla creazione di controller adeguati per le risorse degli utenti e approfondiamo alcuni modelli utili per servizi, middleware, controller e modelli.

Il progetto completo è disponibile su GitHub e il codice alla fine di questo articolo si trova nel toptal-article-01 .