04 - Express

Express

Creare una cartella di lavoro:

mkdir 06-express
cd 06-express
npm init

Nodejs mette a disposizione i moduli http e https tramite i quali è possibile creare dei server http che rimangono in ascolto delle richieste, eseguono elaborazioni e ritornano risposte al client.

In questo corso andremo a sviluppare esclusivamente API Rest, ma una volta aperto un web server si può anche renderizzare pagine o creare servizi GraphQL.

Avvio di un webserver

Tramite la funzione http.createServer è possibile creare il webserver vero è proprio, di per se non fa molto, per ogni richiesta viene eseguita la funzione, l'oggetto req contiene il dettaglio della richiesta e quello res permette di manipolare la risposta.
Il metodo .listen del server creato avvia il server rimanendo in ascolto di una specifica porta.

const http = require('http');

http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello world');
}).listen(3000);

Proviamo ad avviare il server tramite il comando node index.js e navigare alla pagina http://localhost:3000.

Info

Da notare che a differenza degli esempi precedenti abbiamo usato require al posto di import. E' un altro modo di gestire le importazioni (il vecchio metodo se vogliamo). Possiamo anche usare import, ma per farlo è necessario aggiungere sul file package.json la proprietà "type": "module", altrimenti non funziona. Questo problema verrà risolto quando andremo ad utilizzare typescript.

Il modulo base http ha tutte le funzionalità necessarie per gestire qualsiasi tipo di richiesta e tornare qualsiasi risposta, il problema è che è molto base.
Se ad esempio voglio eseguire codice diverso a seconda dell'url richiesto dal client devo andare manualmente ad analizzare l'oggetto req ed eseguire funzioni diverse a seconda dei casi. Oppure per analizzare i dati che mi vengono inviati nel body devo andare a leggere gli header della richiesta per sapere il formato, poi devo andare a decodificare il contenuto del body secondo quel formato, e infine andare a fare le elaborazioni necessarie. Gli stessi problemi li ho al momento della formattazione della risposta, devo settare manualmente headers, status code, contenuto, ecc.

Per questo motivo nascono framework come express che facilitano tutte queste operazioni.

Express

npm install express
import { createServer } from 'http';
import express from 'express';

const app = express();

app.get('/', (req, res, next) => {
  res.status(200);
  res.send('Hello world');
}); 

createServer(app).listen(3000);

Esiste anche la forma semplificata:

import express from 'express';

const app = express();

app.get('/', (req, res, next) => {
  res.status(200);
  res.send('Hello world');
}); 

app.listen(3000, () => console.log('Server started on port 3000'));

come si può vedere abbiamo creato il server allo stesso modo di prima, ma invece della funzione per gestire le chiamate abbiamo passato l'app express creata.
Questo ci dice parecchio sul funzionamento di express. Express fornisce una funzione base che gestisce tutte le chiamate in ingresso, da li poi mette a disposizione una serie di metodi per definire facilmente come quelle chiamate devono essere gestite.

Nel nostro piccolo esempio abbiamo usato app.get per dire al framework che le chiamate di tipo GET alla pagina principale del nostro sito devono essere gestite da quella funzione. Internamente express fa i controlli sull'url e sul method della chiamata, ed esegue le funzioni che abbiamo specificato.

Chiamate HTTP

Ogni richiesta HTTP è composta da diverse parti e ha diverse caratteristiche:

  • url: è l'indirizzo richiesto
  • method: descrive la tipologia di richiesta, può essere GET, POST, PUT, DELETE, PATCH, OPTIONS, ecc
  • headers: sono una serie di attributi chiave-valore che descrivono le caratteristiche della richiesta. (formato dei dati, dimensione, token di autenticazione, formato della risposta richiesta, cache, ecc)
  • body: se presente contiene i dati che vengono inviati assieme alla richiesta, comunemente è usato con le richieste POST e PUT, che sono i tipi che identificano l'aggiunta e la modifica di dati

A sua volta ogni risposta ha una serie di caratteristiche:

  • statusCode: identifica lo stato della risposta in formato numerico
    • 200: ok
    • 201: created
    • 202: accepted, per cancellazioni o operazioni che avviano job
    • 301: moved (redirect)
    • 400: bad request
    • 401: unauthorized
    • 403: forbidden
    • 404: not found
    • 500: internal server error
    • ecc
    • i numeri 200 identificano un successo
    • i numeri 300 identificano un redirect di qualche tipo
    • i numeri 400 identificano degli errori lato client (dati sbagliati, autenticazione, ecc)
    • i numeri 500 identificano degli errori lato server
  • headers: simili a quelli della richiesta ma stavolta danno informazioni al client su come interpretare la riposta
  • body: il contenuto vero e proprio della risposta

Tutte le chiamate http, che siano pagine, file, o richieste di dati tramite API hanno queste caratteristiche.

REST API

REST API (Representational state transfer) è uno stile architetturale che definisce le linee guida per lo sviluppo di API scalabili e stateless.
Una API REST deve essere:

  • indipendente dalla tecnologia di implementazione
  • stateless: ogni richiesta è indipendente da quelle che l'hanno preceduta
  • scalabile: richieste indipendenti permettono di inviare le richieste a server diversi
  • autodescrittiva: tutte le informazioni necessarie ad interpretare le richieste e le risposte sono contenute al loro interno
  • rappresentativa: gli endpoint sono divisi per risorsa e metodo, rendendone semplice l'interpretazione
  • standard: basata su HTTP e con specifiche ben definite, permette di essere utilizzata a prescindere dal linguaggio e dalla piattaforma

Esempio:

  • GET /api/books: torna una lista di elementi di tipo book
  • GET /api/books/5: torna un singolo libro con identificativo 5
  • POST /api/books: crea un nuovo libro
  • PUT /api/books/5: aggiorna completamente il libro 5
  • PATCH /api/books/5: aggiorna parzialmente il libro 5
  • DELETE /api/books/5: elimina il libro 5
  • GET /api/books/5/reviews: torna le review del libro 5
  • POST /api/books/5/reviews: aggiunge una review al libro 5
  • DELETE /api/books/5/reviews/3: elimina la review 3 dal libro 5

I dati trasmessi sono in forma di stringa, vengono quindi serializzati prima di essere trasmessi e deserializzati quando vengono ricevuti. Normalmente i dati sono in formato json, ma in alcuni casi è ancora usato xml.

Prima API - GET

import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import { cart } from './cart.js';

const app = express();

app.use(cors());

app.use(morgan('tiny'));

app.get('/api/cart-items', (req, res, next) => {
  res.status(200);
  res.json(cart);
}); 

app.listen(3000, () => console.log('Server started on port 3000'));

Vediamo però che la funzione che abbiamo passato al metodo .get ha un parametro in più rispetto a quella che abbiamo usato con il modulo http base. Questo perché l'intero framework è basato sul concetto di middleware.

Middleware

Le funzioni middleware sono funzioni con accesso all’oggetto richiesta (req), all’oggetto risposta (res) e alla successiva funzione middleware nel ciclo richiesta-risposta dell’applicazione. La successiva funzione middleware viene comunemente denotata da una variabile denominata next.

middleware.png

Ogni funzione può avere diversi scopi:

  • apportare modifiche agli oggetti req e res
  • terminare il ciclo richiesta-risposta (tornando un risultando o chiudendo la comunicazione)
  • chiamare la successiva funzione di middleware
  • eseguire qualsiasi codice (ad esempio fare il log della richiesta)
app.use(function (req, res, next) {
  console.log('Time:', Date.now());
  next();
});

I middleware permettono di gestire parti comuni alle varie chiamate e parti specifiche delle singole rotte, come as esempio:

  • autenticazione
  • parsing del body
  • logging
  • upload di file a uno specifico url
  • gestione degli errori
  • ecc

Esistono molte librerie che mettono a disposizione dei middleware con funzionalità specifiche e configurabili, andiamo ad esempio ad installare morgan e cors

npm i morgan
npm i cors
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';

const app = express();

app.use(cors());

app.use(morgan('tiny'));

app.use(function (req, res, next) {
  console.log('Time:', Date.now());
  next();
});


app.get('/', (req, res, next) => {
  res.status(200);
  res.send('Hello world');
}); 

app.listen(3000, () => console.log('Server started on port 3000'));

Come possiamo vedere abbiamo usato nuovamente il metodo .use per registrare i middleware che abbiamo importato. .use è un metodo di express che registra un middleware sulle chiamate a prescindere dal metodo con cui arriva la richiesta, a differenza del metodo .get usato in seguito che gestisce solo le richieste di tipo GET.

Live reload e typescript

npm i --save-dev nodemon ts-node

package.json

"scripts": {
    "dev": "nodemon",
    "start": "ts-node src/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "nodemonConfig": {
    "ignore": [
      "**/*.test.ts",
      "**/*.spec.ts",
      "node_modules"
    ],
    "watch": [
      "src"
    ],
    "exec": "npm start",
    "ext": "ts"
  },

Spostiamo il file index e cart dentro una nuova cartella src e cambiamo le estensioni in .ts

tsconfig.json

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitAny": false
  }
}

A questo punto il programma dovrebbe partire con il comando npm run dev, andando a fare una modifica al codice dovrebbe riavviarsi in automatico.

Il codice però funziona perché abbiamo messo skipLibCheck e noImplicitAny nel file tsconfig.
Alcune delle librerie che andremo ad utilizzare sono già scritte in typescript o comunque contengono i file on le definizioni. Altre purtroppo no, in quei casi dobbiamo andare ad installarle manualmente.
Generalmente si trovano con il nome @types/{nome-pacchetto}.

Andiamo ad installare:

npm i --save-dev @types/express @types/morgan @types/cors

A questo punto VSCode dovrebbe darci le definizioni dei metodi che abbiamo usato fino a questo momento.
Andiamo a tipizzare i nostri middleware:

import express, { Request, Response, NextFunction} from 'express';
import cors from 'cors';
import morgan from 'morgan';
import { cart } from './cart';

const app = express();

app.use(cors());

app.use(morgan('tiny'));

app.get('/api/cart-items', (req: Request, res: Response, next: NextFunction) => {
  res.status(200);
  res.json(cart);
}); 

app.listen(3000, () => console.log('Server started on port 3000'));

Progetto

La nostra applicazione avrà una struttura di questo tipo:

  • una lista dei prodotti in sola lettura (immaginiamo ci sia un altro sistema dietro per gestire aggiunta e disponibilità)
    • GET /products - torna una lista di prodotti
    • GET /products/:id - torna un singolo prodotto
  • una serie di API per gestire il carrello
    • GET /cart-items - torna gli elementi del carrello
    • GET /cart-items/:id - torna un singolo elemento del carrello
    • POST /cart-items - aggiunge un elemento al carrello
    • PUT /cart-items/:id - modifica la quantità di un elemento del carrello
    • DELETE /cart-items/:id - rimuove un elemento dal carrello

Vediamo che la struttura rispecchia quello che abbiamo visto prima sulla teoria delle API Rest, API di questo tipo vengono anche definite CRUD (Create Read Update Delete), ad indicare che non fanno operazioni particolari ma gestiscono solo lo stato di vita di un dato.

Generazione dati di esempio

Non avendo una sorgente reale per i prodotti abbiamo bisogno di creare dei dati di esempio con cui lavorare, per farlo usiamo una libreria chiamata faker

Senza andare ad approfondire troppo, è una libreria che ci permette di generare dati casuali con diverse categorie.

npm install --save-dev @faker-js/faker
import { fakerIT as faker } from '@faker-js/faker';

function generateRandomProduct() {
  return {
    id: faker.database.mongodbObjectId(),
    name: faker.commerce.product(),
    description: faker.commerce.productDescription(),
    netPrice: parseFloat(faker.commerce.price()),
    weight: faker.number.int({min: 50, max: 2000}),
    discount: faker.number.float({min: 0, max: 1, precision: 0.01})
  }
}

const tmp = generateRandomProduct();
console.log(tmp);

Per comodità (visto che dobbiamo anche usare typescript) andiamo ad aggiungere uno script al nostro package.json che esegue il file.

  "scripts": {
    "start": "ts-node src/index.ts",
    "dev": "nodemon",
    "gendata": "ts-node example-data.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Vediamo che funzioni e modifichiamo il codice perché vada a generare un file json con 200 prodotti random

import { writeFileSync } from 'node:fs';
import { faker } from '@faker-js/faker/locale/it';

function generateRandomProduct() {
  return {
    id: faker.database.mongodbObjectId(),
    name: faker.commerce.product(),
    description: faker.commerce.productDescription(),
    netPrice: parseFloat(faker.commerce.price()),
    weight: faker.number.int({min: 50, max: 2000}),
    discount: faker.number.float({min: 0, max: 1, precision: 0.01})
  }
}

function generateProducts(num: number) {
  const data = Array.from({length: num}, () => generateRandomProduct());
  writeFileSync('./products.json', JSON.stringify(data), {encoding: 'utf-8'});
}

generateProducts(200);

Con questo ci troviamo con un file che contiene i prodotti messi a disposizione dalla nostra applicazione.

Chiamate e parametri

A seconda del Method di una chiamata HTTP ci sono diversi modi per passare informazioni aggiuntive assieme alla richiesta.

  • Nelle chiamate GET non è presente un body, tutte le informazioni extra che non sono contenute nell'url base vengono passare tramite i QueryParams. Sono una serie chiave-valore codificata sempre come stringa nell'url. La distinzione tra l'url base e i queryParams è definita dal carattere ?, alla chiave è assegnato un valore tramite = e i vari parametri sono separati da &
  • Per PUT/PATCH/POST (e anche DELETE volendo) invece viene comunemente utilizzato il body. Ci sono diversi formati in cui può essere mandato, nel caso di API come le nostre si usa generalmente il JSON. Quindi le informazioni necessarie all'esecuzione (elemento da aggiungere, cosa modificare, ecc), vengono mandate come oggetto nel body.

Query Params

In Express tutte queste informazioni sono contenute sempre all'interno dell'oggetto req.
Per i QueryParams la proprietà da andare a controllare è req.query

app.get("/products", (req, res, next) => {
	console.log(req.query);
	...
});

Ci viene fornito un oggetto contenente tutti i parametri passati con la richiesta, nel caso non ce ne siano l'oggetto è vuoto.

Attenzione

Essendo passati come stringhe nell'url tutti i parametri nell'oggetto sono a loro volta stringhe. E' necessario convertirli manualmente una volta letti o fare in modo che vengano convertiti in un middleware precedente.

Body

Il body è accessibile tramite l'oggetto req.body. In questo caso, essendo passato come json, numeri e boolean sono già nel loro tipo originale, è necessario però occuparsi di convertire date e altri tipi di dato complessi.

Da notare che se proviamo ad accedere al body non riusciamo a leggerlo correttamente, questo perché potrebbe essere in diversi formati. E' compito nostro definire che formato accettiamo e configurare Express per andare ad interpretarlo nel modo corretto.

npm i body-parser
npm i -D @types/body-parser
import express, { Request, Response, NextFunction} from 'express';
import cors from 'cors';
import morgan from 'morgan';
import bodyParser from 'body-parser';

const app = express();

app.use(cors());
app.use(morgan('tiny'));
app.use(bodyParser.json());

...

app.post("/cart-items", (req, res, next) => {
	console.log(req.body);
})

Se riproviamo a leggere il body adesso troviamo correttamente l'oggetto.

Params

C'è anche un altro tipo di informazione che ci servirà per sviluppare le nostre API, non fa parte dello standard HTML ma ci viene fornita da express stavolta.
Se guardiamo gli endpoint che abbiamo definito vediamo ad esempio:

  • GET /products/:id
  • GET /cart-items/:id
  • PUT /cart-items/:id
  • DELETE /cart-items/:id

il parametro :id fa a tutti gli effetti parte dell'url base della mia chiamata, non è un queryParam, una chiamata ad esempio sarà:
GET /products/a232asd12131

Quindi abbiamo degli url "varibili", che a seconda del valore in un segmento (parte contenuta tra due /) eseguiranno azioni su dati diversi.
In Express questi parametri sono tornati nell'oggetto req.params

app.get("/cart-items/:id", (req, res, next) => {
	console.log(req.params.id);
})