Articolo originale: REST API Design Best Practices Handbook – How to Build a REST API with JavaScript, Node.js, and Express.js

Ho creato e utilizzato molte API negli ultimi anni. In questo periodo, mi sono imbattuto in buone e cattive pratiche e ho sperimentato brutte situazioni durante l'utilizzo e la creazione di API. Ma ci sono stati anche grandi momenti.

Ci sono articoli utili online che presentano molte migliori pratiche, ma secondo me molti di essi mancano di praticità. Conoscere la teoria con pochi esempi va bene, ma mi sono sempre chiesto come sarebbe l'implementazione in un esempio più attinente al mondo reale.

Fornire esempi semplici aiuta nella comprensione del concetto stesso senza molta complessità, ma in pratica le cose non sono sempre così semplici. Sono piuttosto sicuro che sai di cosa sto parlando 😁

Ecco perché ho deciso di scrivere questo tutorial. Ho unito insieme tutte le cose (buone e meno buone) che ho appreso in un articolo fruibile fornendo un esempio pratico che può essere seguito. Alla fine, creeremo un'API completa mentre implementeremo una buona pratica dopo l'altra.

Alcune cose da ricordare prima di partire:

Le buone pratiche, come potresti avere intuito, non sono leggi o regole specifiche da seguire. Sono convenzioni o suggerimenti che si sono evoluti nel tempo e si sono rivelati efficaci. Al giorno d'oggi, alcune sono diventate lo standard. Tuttavia questo non vuol dire che tu le debba adottare in toto.

Dovrebbero fornirti una direzione per rendere la tua API migliore per quanto riguarda l'esperienza utente (sia chi la usa che chi la crea), la sicurezza e le prestazioni.

Tieni a mente che i progetti sono diversi e richiedono diversi approcci. Potrebbero esserci situazioni nelle quali non puoi o non dovresti seguire certe convenzioni. Quindi ogni sviluppatore deve prendere queste decisioni in autonomia o con i suoi colleghi.

Ora che abbiamo tolto di mezzo queste cose, senza ulteriori indugi, mettiamoci al lavoro!

Sommario

Il Nostro Progetto di Esempio

alvaro-reyes-qWwpHwip31M-unsplash--1-
Photo by Alvaro Reyes on Unsplash

Prima di iniziare l'implementazione delle migliori pratiche nel nostro progetto di esempio, ti faccio una breve introduzione di ciò che andremo a creare.

Creeremo un'API REST per una Applicazione di Allenamento CrossFit. Se non sei familiare con il CrossFit, si tratta di un metodo di fitness e uno sport competitivo che combina allenamenti ad alta intensità con elementi da parecchi sport (sollevamento pesi, ginnastica e altri).

Nella nostra applicazione vorremmo creare, leggere, aggiornare e cancellare i WOD (Workouts of the Day - Allenamenti della giornata). Questo aiuterà i nostri utenti (vale a dire i proprietari di palestre) a generare piani di allenamento e tracciare i propri allenamenti all'interno di una singola applicazione. Inoltre potranno anche aggiungere qualche importante suggerimento di allenamento per ciascuna sessione.

Il nostro compito sarà la progettazione e l'implementazione di un'API per quell'applicazione.

Prerequisiti

Per seguire il tutorial dovrai avere qualche esperienza di JavaScript, Node.js, Express.js e di architettura di backend. Termini come REST e API non dovrebbero essere nuovi per te e dovresti avere una comprensione del Modello Client-Server.

Naturalmente non devi essere un esperto in questi campi, ma averne familiarità e possibilmente qualche esperienza dovrebbe essere sufficiente.

Se non hai tutti i prerequisiti, ovviamente non è un motivo per saltare questo tutorial. C'è ancora molto da imparare qui anche per te. Ma avere queste conoscenze ti renderà più facile seguire.

Anche se questa API è scritta in JavaScript e usa Express, le migliori pratiche non si limitano a questi strumenti. Possono essere applicate anche ad altri linguaggi di programmazione o framework.

Architettura

Come discusso qui sopra, useremo Express.js per la nostra API. Non voglio creare un'architettura complessa, quindi mi piacerebbe attenermi all'architettura a 3 livelli:

Bildschirmfoto-2022-04-25-um-14.33.24-1

All'interno del Controller gestiremo tutto ciò che abbia relazione con HTTP. Il che vuol dire che avremo a che fare con richieste e risposte per i nostri endpoint. Sopra questo livello c'è anche un piccolo Router da Express che instrada le richieste al controller corrispondente.

Tutta la logica di business sarà nel Service Layer (Livello Servizio) che esporta certi servizi (metodi) usati dal controller.

Il terzo livello, Data Access Layer  (Livello di Accesso Dati) sarà dove lavoreremo con il nostro database. Esporteremo alcuni metodi per certe operazioni di database come creare un WOD (allenamento del giorno) che può essere usato dal nostro Service Layer.

Nel nostro esempio non useremo un vero database come MongoDB o PostgreSQL in quanto preferirei concentrarmi maggiormente sulle migliori pratiche stesse. Pertanto useremo un file JSON locale che imita il nostro database. Tuttavia questa logica può essere trasferita ad altri database, naturalmente.

Impostazione di Base

Ora dovremmo essere in grado di creare l'impostazione di base per la nostra API. Non complicheremo eccessivamente le cose e costruiremo una struttura di progetto semplice ma organizzata.

Per prima cosa creiamo la struttura di directory generale con tutti i file necessari e le dipendenze. Quindi eseguiremo un piccolo test per verificare che tutto venga eseguito correttamente:

# Crea la directory del progetto e portati su di essa
mkdir crossfit-wod-api && cd crossfit-wod-api
# Crea una cartella src e portati su di essa
mkdir src && cd src
# Crea le sotto cartelle
mkdir controllers && mkdir services && mkdir database && mkdir routes
# Crea un file indice (il punto di entrata della nostra API)
touch index.js
# Attualmente ci troviamo nella cartella src, quindi dobbiamo prima salire di un livello 
cd .. 

# Crea il file package.json 
npm init -y

Installa le dipendenze per l'impostazione di base:

# Dipendenze di sviluppo
npm i -D nodemon 

# Dipendenze 
npm i express

Apri il progetto con il tuo editor di testo preferito e configura Express:

// In src/index.js 
const express = require("express"); 

const app = express(); 
const PORT = process.env.PORT || 3000; 

// A scopo di test 
app.get("/", (req, res) => { 
    res.send("<h2>It's Working!</h2>"); 
}); 

app.listen(PORT, () => { 
    console.log(`API is listening on port ${PORT}`); 
});

Aggiungi un nuovo script chiamato "dev" all'interno di package.json:

{
  "name": "crossfit-wod-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon src/index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^2.0.15"
  },
  "dependencies": {
    "express": "^4.17.3"
  }
}

Lo script si assicura che il server di sviluppo venga riavviato automaticamente quando eseguiamo delle modifiche (grazie a nodemon).

Lancia il server di sviluppo:

npm run dev

Se osservi il tuo terminale, dovresti vedere un messaggio che dice "API is listening on port 3000".

All'interno del tuo browser vai a localhost:3000. Se tutto è impostato correttamente, dovresti vedere quanto segue:

Bildschirmfoto-2022-04-30-um-11.09.44

Grande! Ora tutto è impostato per implementare le migliori pratiche.

Migliori Pratiche per API REST

constantin-wenning-idDvA4jPBO8-unsplash--1-
Photo by Constantin Wenning on Unsplash

Bene! Ora che abbiamo un'impostazione veramente di base per Express, possiamo estendere la nostra API con le seguenti migliori pratiche.

Iniziamo in modo semplice con i nostri endpoint CRUD fondamentali. Successivamente estenderemo l'API con ciascuna migliore pratica.

Versionamento

Aspetta un secondo. Prima di scrivere codice specifico per qualsiasi API dobbiamo essere consapevoli del versionamento. Come in ogni altra applicazione, ci saranno miglioramenti, nuove funzionalità e cose di questo genere. Quindi è importante applicare un buon versionamento anche alla nostra API.

Il grande vantaggio è che possiamo lavorare con nuove funzionalità o miglioramenti su una nuova versione mentre i client stanno ancora usando la versione corrente e non saranno interessati da modifiche non retro compatibili.

Non forzeremo neanche i client ad adottare la nuova versione immediatamente. Possono usare la versione corrente e migrare in autonomia quando la nuova versione è stabile.

La versione corrente e la nuova sono praticamente in esecuzione in parallelo e non interferiscono tra loro.

Come possiamo differenziare le versioni? Una buona pratica è aggiungere un segmento di percorso come v1 o v2 all'interno dell'URL.

// Versione 1 
"/api/v1/workouts" 

// Versione 2 
"/api/v2/workouts" 

// ...

Ecco ciò che esporremo al mondo esterno, quello che sarà utilizzato da altri sviluppatori. Dobbiamo anche strutturare il nostro progetto per poter differenziare ogni versione.

Ci sono molti approcci diversi per la gestione del versionamento all'interno di una API Express. Nel nostro caso, vorrei creare una sotto cartella per ogni versione all'interno della directory src chiamata v1, v2 e così via.

mkdir src/v1

Ora spostiamo la cartella routes nella nuova directory v1.

# Ottieni il percorso della tua directory corrente (copialo)
pwd 

# Sposta "routes" in "v1" (inserisci il percorso ricavato qui sopra in {pwd}) 
mv {pwd}/src/routes {pwd}/src/v1

La nuova directory /src/v1/routes conterrà tutte le nostre rotte per la versione 1. Aggiungeremo "vero" contenuto successivamente. Per ora aggiungiamo un semplice file index.js per vedere se funziona.

# In /src/v1/routes 
touch index.js

All'interno del file inseriamo il codice per avviare un semplice router.

// In src/v1/routes/index.js
const express = require("express");
const router = express.Router();

router.route("/").get((req, res) => {
  res.send(`<h2>Hello from ${req.baseUrl}</h2>`);
});

module.exports = router;

Ora dobbiamo attaccare il nostro router per v1 all'interno del punto di entrata radice nel file src/index.js.

// In src/index.js
const express = require("express");
// *** AGGIUNGI ***
const v1Router = require("./v1/routes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** RIMUOVI ***
app.get("/", (req, res) => {
  res.send("<h2>It's Working!</h2>");
});

// *** AGGIUNGI ***
app.use("/api/v1", v1Router);

app.listen(PORT, () => {
  console.log(`API is listening on port ${PORT}`);
});

Ora nel tuo browser vai a localhost:3000/api/v1 e dovresti vedere quanto segue:

Bildschirmfoto-2022-04-30-um-11.22.28

Congratulazioni! Hai appena strutturato il progetto per gestire diverse versioni. Ora passiamo le richieste in arrivo su /api/v1 alla versione 1 del nostro router, il quale successivamente instraderà ogni richiesta al metodo del controller corrispondente.

Prima di continuare, vorrei sottolineare una cosa.

Abbiamo appena spostato la nostra cartella routes all'interno della directory v1. Le altre cartelle come controllers o services rimangono ancora nella directory src. Questo va bene per adesso in quanto stiamo creando un'API piuttosto piccola. Possiamo usare gli stessi controller e servizi in ogni versione globalmente.

Quando l'API sta crescendo e richiede diversi metodi di controller specifici per la versione v2, per esempio, sarebbe una idea migliore spostare la cartella controllers all'interno della directory v2 in modo di avere tutta la logica specifica per quella particolare versione incapsulata.

Un'altra ragione per fare questo potrebbe essere che potremmo cambiare un servizio che è usato da tutte le altre versioni. Non vogliamo che ci siano problemi di compatibilità con altre versioni. Pertanto sarebbe una decisione saggia spostare anche la cartella services all'interno della cartella della specifica versione.

Come detto in precedenza, tuttavia, nel nostro esempio ritengo che vada bene differenziare solo le rotte e lasciare che il router gestisca il resto. Cionondimeno è importante tenere a mente di avere una struttura pulita quando l'API cresce di dimensioni e necessita di modifiche.

Nomi di Risorse al Plurale

Dopo aver impostato tutto possiamo concentrarci sulla vera implementazione della nostra API. Come ho detto, vorrei iniziare con gli endpoint CRUD fondamentali.

In altre parole, iniziamo implementando gli endpoint per creare, leggere, aggiornare ed eliminare gli allenamenti.

Per prima cosa creiamo un controller, un servizio e un router specifici per i nostri allenamenti (workout).

touch src/controllers/workoutController.js 

touch src/services/workoutService.js 

touch src/v1/routes/workoutRoutes.js

Mi piace sempre partire prima con le rotte. Pensiamo come denominare i nostri endpoint. Ciò va di pari passo con questa particolare miglior pratica.

Possiamo denominare l'endpoint di creazione /api/v1/workout in quanto vorremmo aggiungere un altro allenamento, giusto? Praticamente c'è nulla di sbagliato con questo approccio, ma può portare a fraintendimenti.

Ricorda sempre: la tua API viene usata da altri esseri umani e dovrebbe essere precisa. Questo si applica anche per la denominazione delle tue risorse.

Ho sempre immaginato una risorsa come una scatola. Nel nostro esempio la scatola è una collezione che contiene diversi allenamenti (workouts).

Usare il plurale per denominare le tue risorse ha il grande vantaggio che per le altre persone risulta chiaro che si tratta di una collezione di diversi allenamenti.

Pertanto definiamo i nostri endpoint all'interno del router nel file workoutRoutes.js.

// In src/v1/routes/workoutRoutes.js
const express = require("express");
const router = express.Router();

router.get("/", (req, res) => {
  res.send("Get all workouts");
});

router.get("/:workoutId", (req, res) => {
  res.send("Get an existing workout");
});

router.post("/", (req, res) => {
  res.send("Create a new workout");
});

router.patch("/:workoutId", (req, res) => {
  res.send("Update an existing workout");
});

router.delete("/:workoutId", (req, res) => {
  res.send("Delete an existing workout");
});

module.exports = router;

Puoi eliminare il file di test index.js all'interno di src/v1/routes.

Ora passiamo ai nostri punti di entrata e agganciamo il nostro router per la versione v1 (v1WorkoutRouter).

// In src/index.js
const express = require("express");
// *** RIMUOVI ***
const v1Router = require("./v1/routes");
// *** AGGIUNGI ***
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** RIMUOVI ***
app.use("/api/v1", v1Router);

// *** AGGIUNGI ***
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
  console.log(`API is listening on port ${PORT}`);
});

È andato tutto liscio, giusto? Ora catturiamo tutte le richieste verso /api/v1/workouts con il nostro v1WorkoutRouter.

All'interno del nostro router chiameremo un metodo diverso gestito dal nostro controller per ciascun endpoint.

Creiamo un metodo per ciascun endpoint. Inviamo semplicemente un messaggio di risposta, dovrebbe bastare per adesso.

// In src/controllers/workoutController.js
const getAllWorkouts = (req, res) => {
  res.send("Get all workouts");
};

const getOneWorkout = (req, res) => {
  res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
  res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
  res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
  res.send("Delete an existing workout");
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Ora è tempo di rifattorizzare un po' il nostro router e usare i metodi del controller.

// In src/v1/routes/workoutRoutes.js
const express = require("express");
const workoutController = require("../../controllers/workoutController");

const router = express.Router();

router.get("/", workoutController.getAllWorkouts);

router.get("/:workoutId", workoutController.getOneWorkout);

router.post("/", workoutController.createNewWorkout);

router.patch("/:workoutId", workoutController.updateOneWorkout);

router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

Ora possiamo testare le nostre richieste GET per l'endopoint /api/v1/workouts/:workoutId digitando localhost:3000/api/v1/workouts/2342 nel browser. Dovresti vedere qualcosa tipo questo:

Bildschirmfoto-2022-04-30-um-11.29.19

Ci siamo riusciti! Il primo livello della nostra architettura è fatto. Creiamo ora il livello del servizio implementando la nostra prossima migliore pratica.

Accettare e Rispondere con Dati in Formato JSON

Quando interagisci con un'API, invii sempre dati specifici con la tua richiesta oppure ricevi dati con la risposta. Ci sono molti formati di dato diversi ma JSON (JavaScript Object Notation) è un formato standardizzato.

Anche se JSON contiene il termine JavaScript, non legato specificatamente a questo linguaggio. Puoi scrivere le tue API anche in Java o Python, che sono anch'essi in grado di gestire bene JSON.

A causa della sua standardizzazione, l'API dovrebbe accettare e rispondere con dati in formato JSON.

Diamo uno sguardo alla nostra implementazione corrente e vediamo come possiamo integrare questa miglior pratica.

Per prima cosa creiamo il nostro livello di servizio.

// In src/services/workoutService.js
const getAllWorkouts = () => {
  return;
};

const getOneWorkout = () => {
  return;
};

const createNewWorkout = () => {
  return;
};

const updateOneWorkout = () => {
  return;
};

const deleteOneWorkout = () => {
  return;
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

È anche una buona pratica denominare i metodi del servizio allo stesso modo dei metodi del controller, in modo da avere una connessione tra questi. Iniziamo con il restituire nulla.

All'interno del controller nel file workoutController.js possiamo usare questi metodi.

// In src/controllers/workoutController.js
// *** AGGIUNGI***
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
  // *** AGGIUNGI ***
  const allWorkouts = workoutService.getAllWorkouts();
  res.send("Get all workouts");
};

const getOneWorkout = (req, res) => {
  // *** AGGIUNGI ***
  const workout = workoutService.getOneWorkout();
  res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
  // *** AGGIUNGI ***
  const createdWorkout = workoutService.createNewWorkout();
  res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
  // *** AGGIUNGI ***
  const updatedWorkout = workoutService.updateOneWorkout();
  res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
  // *** AGGIUNGI ***
  workoutService.deleteOneWorkout();
  res.send("Delete an existing workout");
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Al momento non dovrebbe cambiare nulla all'interno delle nostre risposte. Tuttavia sotto il cofano il livello controller ora parla con il livello servizio.

All'interno dei metodi del servizio gestiremo la nostra logica di business come la trasformazione delle strutture dati e la comunicazione con il livello database.

Per fare questo, ci serve un database e una collezione di metodi che gestisca effettivamente l'interazione con il database. Il nostro database sarà un semplice file JSON che abbiamo già valorizzato in precedenza con qualche allenamento.

# Crea un nuovo file chiamato db.json all'interno di src/database 
touch src/database/db.json 

# Crea un file workout.js che contiene tutti i metodi specifici per l'allenamento in /src/database 
touch src/database/Workout.js

Copia quanto segue in db.json:

{
  "workouts": [
    {
      "id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
      "name": "Tommy V",
      "mode": "For Time",
      "equipment": [
        "barbell",
        "rope"
      ],
      "exercises": [
        "21 thrusters",
        "12 rope climbs, 15 ft",
        "15 thrusters",
        "9 rope climbs, 15 ft",
        "9 thrusters",
        "6 rope climbs, 15 ft"
      ],
      "createdAt": "4/20/2022, 2:21:56 PM",
      "updatedAt": "4/20/2022, 2:21:56 PM",
      "trainerTips": [
        "Split the 21 thrusters as needed",
        "Try to do the 9 and 6 thrusters unbroken",
        "RX Weights: 115lb/75lb"
      ]
    },
    {
      "id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "name": "Dead Push-Ups",
      "mode": "AMRAP 10",
      "equipment": [
        "barbell"
      ],
      "exercises": [
        "15 deadlifts",
        "15 hand-release push-ups"
      ],
      "createdAt": "1/25/2022, 1:15:44 PM",
      "updatedAt": "3/10/2022, 8:21:56 AM",
      "trainerTips": [
        "Deadlifts are meant to be light and fast",
        "Try to aim for unbroken sets",
        "RX Weights: 135lb/95lb"
      ]
    },
    {
      "id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
      "name": "Heavy DT",
      "mode": "5 Rounds For Time",
      "equipment": [
        "barbell",
        "rope"
      ],
      "exercises": [
        "12 deadlifts",
        "9 hang power cleans",
        "6 push jerks"
      ],
      "createdAt": "11/20/2021, 5:39:07 PM",
      "updatedAt": "11/20/2021, 5:39:07 PM",
      "trainerTips": [
        "Aim for unbroken push jerks",
        "The first three rounds might feel terrible, but stick to it",
        "RX Weights: 205lb/145lb"
      ]
    }
  ]
}

Come puoi vedere ci sono tre allenamenti inseriti. Un allenamento contiene i campi id, name, mode, equipment, exercises, createdAt, updatedAt, e trainerTips, (rispettivamente  id, nome, modalità, equipaggiamenti, esercizi, creatoIl, aggiornatoIl e suggerimentiAllenatore - n.d.t.).

Partiamo con il più semplice e ritorniamo tutti gli allenamenti che sono stati salvati, poi iniziamo a implementare il metodo corrispondente all'interno del livello di accesso dati (src/database/Workout.js).

Ancora una volta, ho scelto di denominare il metodo qui con lo stesso nome di quello del servizio e del controller, ma è completamente opzionale.

// In src/database/Workout.js
const DB = require("./db.json");

const getAllWorkouts = () => {
  return DB.workouts;
};

module.exports = { getAllWorkouts };

Torniamo nuovamente al servizio nel file workoutService.js e implementiamo la logica per getAllWorkouts.

// In src/services/workoutService.js
// *** AGGIUNGI ***
const Workout = require("../database/Workout");
const getAllWorkouts = () => {
  // *** AGGIUNGI ***
  const allWorkouts = Workout.getAllWorkouts();
  // *** AGGIUNGI ***
  return allWorkouts;
};

const getOneWorkout = () => {
  return;
};

const createNewWorkout = () => {
  return;
};

const updateOneWorkout = () => {
  return;
};

const deleteOneWorkout = () => {
  return;
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Ritornare tutti gli allenamenti è piuttosto semplice e non dobbiamo fare trasformazioni in quanto è già un file JSON. Non dobbiamo neppure ricevere alcun argomento per ora. Quindi questa implementazione è piuttosto semplice, ma ci torneremo sopra in futuro.

Tornando al controller in workoutControllers.js riceviamo il valore di ritorno da workoutService.getAllWorkouts() e lo inviamo semplicemente al client. Abbiamo girato al controller la risposta dal database tramite il nostro servizio.

// In src/controllers/workoutControllers.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
  const allWorkouts = workoutService.getAllWorkouts();
  // *** AGGIUNGI ***
  res.send({ status: "OK", data: allWorkouts });
};

const getOneWorkout = (req, res) => {
  const workout = workoutService.getOneWorkout();
  res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
  const createdWorkout = workoutService.createNewWorkout();
  res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
  const updatedWorkout = workoutService.updateOneWorkout();
  res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
  workoutService.deleteOneWorkout();
  res.send("Delete an existing workout");
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Nel browser vai a localhost:3000/api/v1/workouts e dovresti vedere la risposta in formato JSON.

Bildschirmfoto-2022-04-30-um-11.38.14

È andata alla grande! Rispondiamo con dati in formato JSON. E per quanto riguarda quello che accettiamo? Pensiamo a un endpoint dove riceviamo dati JSON dal client. L'endpoint per creare o aggiornare un allenamento necessita di dati dal client.

All'interno del controller estraiamo il corpo della richiesta per creare un nuovo allenamento e lo passiamo al servizio. All'interno del servizio lo inseriamo nel nostro DB.json e inviamo l'allenamento appena creato al client.

Per essere in grado di elaborare JSON inviato all'interno del corpo di una richiesta dobbiamo prima installare body-parser e configurarlo.

npm i body-parser
// In src/index.js 
const express = require("express");
// *** AGGIUNGI ***
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** AGGIUNGI ***
app.use(bodyParser.json());
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
  console.log(`API is listening on port ${PORT}`);
});

Ora siamo in grado di ricevere dati in formato JSON all'interno del nostro controller in req.body.

Per eseguire un test appropriato, apriamo il nostro client HTTP preferito (io uso Postman), creiamo una richiesta POST a localhost:3000/api/v1/workouts e nel corpo della richiesta inseriamo in formato JSON quanto segue:

{
  "name": "Core Buster",
  "mode": "AMRAP 20",
  "equipment": [
    "rack",
    "barbell",
    "abmat"
  ],
  "exercises": [
    "15 toes to bars",
    "10 thrusters",
    "30 abmat sit-ups"
  ],
  "trainerTips": [
    "Split your toes to bars into two sets maximum",
    "Go unbroken on the thrusters",
    "Take the abmat sit-ups as a chance to normalize your breath"
  ]
}

Come potresti aver notato, ci sono alcune proprietà mancanti come "id", "createdAt" e "updatedAt". È compito della nostra API aggiungere queste proprietà prima dell'inserimento. Ce ne occuperemo all'interno del servizio che gestisce gli allenamenti successivamente.

All'interno del metodo createNewWorkout nel controller, nel file workoutController.js, possiamo estrarre il corpo dall'oggetto request, effettuare qualche validazione e passarlo come argomento per il nostro servizio.

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
  // *** AGGIUNGI ***
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    return;
  }
  // *** AGGIUNGI ***
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  // *** AGGIUNGI ***
  const createdWorkout = workoutService.createNewWorkout(newWorkout);
  // *** AGGIUNGI ***
  res.status(201).send({ status: "OK", data: createdWorkout });
};

...

Per migliorare la validazione della richiesta in genere vorrai usare un pacchetto di terze parti come express-validator.

Passiamo al servizio che gestisce gli allenamenti e riceviamo i dati dal controller all'interno del metodo createNewWorkout.

Dopo avere aggiunto le proprietà mancanti all'oggetto, lo passiamo a un nuovo metodo nel nostro livello di accesso dati per conservarlo nel nostro DB.

Per prima cosa creiamo una semplice funzione di utilità che sovrascrive il nostro file JSON per salvare i nuovi dati.

# Crea un file utils.js all'interno della directory database 
touch src/database/utils.js
// In src/database/utils.js
const fs = require("fs");

const saveToDatabase = (DB) => {
  fs.writeFileSync("./db.json", JSON.stringify(DB, null, 2), {
    encoding: "utf-8",
  });
};

module.exports = { saveToDatabase };

Poi possiamo usare questa funzione nel nostro file Workout.js.

// In src/database/Workout.js
const DB = require("./db.json");
// *** AGGIUNGI ***
const { saveToDatabase } = require("./utils");


const getAllWorkouts = () => {
  return DB.workouts;
};

// *** AGGIUNGI ***
const createNewWorkout = (newWorkout) => {
  const isAlreadyAdded =
    DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
  if (isAlreadyAdded) {
    return;
  }
  DB.workouts.push(newWorkout);
  saveToDatabase(DB);
  return newWorkout;
};

module.exports = {
  getAllWorkouts,
  // *** AGGIUNGI ***
  createNewWorkout,
};

È andato tutto bene! Il passaggio successivo consiste nell'utilizzare i metodi del database all'interno del nostro servizio.

# Installa il pacchetto uuid 
npm i uuid
// In src/services/workoutService.js
// *** AGGIUNGI ***
const { v4: uuid } = require("uuid");
// *** AGGIUNGI ***
const Workout = require("../database/Workout");

const getAllWorkouts = () => {
    const allWorkouts = Workout.getAllWorkouts();
    return allWorkouts;
};

const getOneWorkout = () => {
  return;
};

const createNewWorkout = (newWorkout) => {
  // *** AGGIUNGI ***
  const workoutToInsert = {
    ...newWorkout,
    id: uuid(),
    createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  // *** AGGIUNGI ***
  const createdWorkout = Workout.createNewWorkout(workoutToInsert);
  return createdWorkout;
};

const updateOneWorkout = () => {
  return;
};

const deleteOneWorkout = () => {
  return;
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Wow! Divertente, non è vero? Ora puoi usare il tuo client HTTP e inviare la richiesta POST nuovamente, dovresti ricevere il nuovo allenamento creato in formato  JSON.

Se provi ad aggiungere lo stesso allenamento nuovamente, riceverai un codice di stato 201, senza che l'allenamento venga inserito.

Questo significa che il nostro metodo di database per ora cancella gli inserimenti e non ritorna nulla. Infatti la nostra istruzione if verifica se un allenamento con lo stesso nome si trova già nel database. Per ora va bene così, gestiremo questa casistica nella nostra prossima miglior pratica!

Ora nel tuo browser invia una richiesta GET a localhost:3000/api/v1/workouts per ottenere tutti gli allenamenti. Dovresti vedere che il nostro allenamento è stato inserito e salvato con successo:

Bildschirmfoto-2022-04-30-um-11.57.23

Puoi implementare gli altri metodi in autonomia oppure puoi semplicemente copiare le mia implementazioni.

Per primo il controller (puoi copiare semplicemente tutto il contenuto):

// In src/controllers/workoutController.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
  const allWorkouts = workoutService.getAllWorkouts();
  res.send({ status: "OK", data: allWorkouts });
};

const getOneWorkout = (req, res) => {
  const {
    params: { workoutId },
  } = req;
  if (!workoutId) {
    return;
  }
  const workout = workoutService.getOneWorkout(workoutId);
  res.send({ status: "OK", data: workout });
};

const createNewWorkout = (req, res) => {
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    return;
  }
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  const createdWorkout = workoutService.createNewWorkout(newWorkout);
  res.status(201).send({ status: "OK", data: createdWorkout });
};

const updateOneWorkout = (req, res) => {
  const {
    body,
    params: { workoutId },
  } = req;
  if (!workoutId) {
    return;
  }
  const updatedWorkout = workoutService.updateOneWorkout(workoutId, body);
  res.send({ status: "OK", data: updatedWorkout });
};

const deleteOneWorkout = (req, res) => {
  const {
    params: { workoutId },
  } = req;
  if (!workoutId) {
    return;
  }
  workoutService.deleteOneWorkout(workoutId);
  res.status(204).send({ status: "OK" });
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Quindi il servizio (puoi copiare semplicemente tutto il contenuto):

// In src/services/workoutServices.js
const { v4: uuid } = require("uuid");
const Workout = require("../database/Workout");

const getAllWorkouts = () => {
  const allWorkouts = Workout.getAllWorkouts();
  return allWorkouts;
};

const getOneWorkout = (workoutId) => {
  const workout = Workout.getOneWorkout(workoutId);
  return workout;
};

const createNewWorkout = (newWorkout) => {
  const workoutToInsert = {
    ...newWorkout,
    id: uuid(),
    createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  const createdWorkout = Workout.createNewWorkout(workoutToInsert);
  return createdWorkout;
};

const updateOneWorkout = (workoutId, changes) => {
  const updatedWorkout = Workout.updateOneWorkout(workoutId, changes);
  return updatedWorkout;
};

const deleteOneWorkout = (workoutId) => {
  Workout.deleteOneWorkout(workoutId);
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Infine implementiamo i nostri metodi di database all'interno del livello di accesso dati (puoi copiare semplicemente tutto il contenuto):

// In src/database/Workout.js
const DB = require("./db.json");
const { saveToDatabase } = require("./utils");

const getAllWorkouts = () => {
  return DB.workouts;
};

const getOneWorkout = (workoutId) => {
  const workout = DB.workouts.find((workout) => workout.id === workoutId);
  if (!workout) {
    return;
  }
  return workout;
};

const createNewWorkout = (newWorkout) => {
  const isAlreadyAdded =
    DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
  if (isAlreadyAdded) {
    return;
  }
  DB.workouts.push(newWorkout);
  saveToDatabase(DB);
  return newWorkout;
};

const updateOneWorkout = (workoutId, changes) => {
  const indexForUpdate = DB.workouts.findIndex(
    (workout) => workout.id === workoutId
  );
  if (indexForUpdate === -1) {
    return;
  }
  const updatedWorkout = {
    ...DB.workouts[indexForUpdate],
    ...changes,
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  DB.workouts[indexForUpdate] = updatedWorkout;
  saveToDatabase(DB);
  return updatedWorkout;
};

const deleteOneWorkout = (workoutId) => {
  const indexForDeletion = DB.workouts.findIndex(
    (workout) => workout.id === workoutId
  );
  if (indexForDeletion === -1) {
    return;
  }
  DB.workouts.splice(indexForDeletion, 1);
  saveToDatabase(DB);
};

module.exports = {
  getAllWorkouts,
  createNewWorkout,
  getOneWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Grande! Ora passiamo alla prossima miglior pratica e vediamo come possiamo gestire gli errori in modo appropriato.

Rispondere con Codici di Errore Standard HTTP

Siamo già arrivati piuttosto lontano, ma non abbiamo ancora finito. La nostra API ha ora la capacità di gestire operazioni CRUD di base con conservazione dati. Grande, ma non l'ideale tuttavia.

Come mai? Fammi spiegare.

In un mondo perfetto va tutto liscio senza alcun errore. Ma come probabilmente saprai nel modo reale si possono verificare molti errori, da una prospettiva sia umana che tecnica.

Probabilmente potresti conoscere quella strana sensazione quando le cose funzionano bene fin dall'inizio senza errori. Questo è fantastico e divertente, ma come sviluppatori siamo più abituati a cose che non funzionano correttamente. 😁

Lo stesso vale per la nostra API. Dovremmo gestire quelle situazioni nelle quali le cose potrebbero andare male oppure generare un errore. Questo contribuirà a rendere la nostra API più robusta.

Quando qualcosa non va (sia nella richiesta che all'interno della nostra API) restituiamo un codice di errore HTTP. Ho visto e usato API che ritornavano sempre il codice di errore 400 quando una richiesta conteneva errori senza alcun messaggio specifico sul PERCHÉ  questo errore si era verificato o quale fosse. Pertanto il debug diventava problematico.

Ecco la ragione per la quale è sempre buona pratica ritornare il codice di errore HTTP appropriato per ciascun caso. Questo aiuta l'utilizzatore o lo sviluppatore che ha creato l'API a identificare il problema più facilmente.

Per migliorare l'esperienza possiamo anche inviare un veloce messaggio di errore assieme al codice dell'errore con la risposta. Ma come ho scritto nell'introduzione, questo non è sempre saggio e dovrebbe essere deciso dal programmatore stesso.

Per esempio, ci si dovrebbe pensare bene prima di ritornare qualcosa come "Questo username ha già effettuato il login" in quanto si forniscono informazioni sui tuoi utenti che andrebbero tenute nascoste.

Nella nostra API Crossfit esaminiamo la creazione di un endpoint e vediamo quali errori potrebbero verificarsi e come gestirli. Alla fine di questa sezione troverai nuovamente l'implementazione completa per gli altri endpoint.

Iniziamo con il metodo createNewWorkout all'interno del controller:

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    return;
  }
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  const createdWorkout = workoutService.createNewWorkout(newWorkout);
  res.status(201).send({ status: "OK", data: createdWorkout });
};

...

Abbiamo già intercettato il caso nel quale il corpo della richiesta non è composto correttamente e mancano chiavi che sono richieste.

Questo sarebbe un buon esempio per ritornare un errore HTTP 400 con un relativo messaggio di errore che (tradotto - n.d.t.) dice: "Una delle seguenti chiavi è mancante o vuota nel corpo della richiesta: 'name', 'mode', 'equipment', 'exercises', 'trainerTips".

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: {
          error:
            "One of the following key is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
        },
      });
    return;
  }
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  const createdWorkout = workoutService.createNewWorkout(newWorkout);
  res.status(201).send({ status: "OK", data: createdWorkout });
};

...

Se cerchi di aggiungere un nuovo allenamento ma dimentichi di passare la proprietà "mode" nel corpo della richiesta, dovresti vedere un messaggio di errore assieme al codice di errore HTTP 400.

Bildschirmfoto-2022-04-30-um-15.17.21

Uno sviluppatore che utilizza l'API è ora meglio informato su cosa cercare. Saprà immediatamente di dover cercare all'interno del corpo della richiesta per verificare se non sia stata fornita una delle proprietà richieste.

Lasciare questo messaggio di errore più generico per tutte le proprietà per ora va bene. In genere dovresti usare un validatore di schema per gestire questo.

Andiamo a un livello più profondo nel nostro servizio workout e vediamo quali potenziali errori si potrebbero verificare.

// In src/services/workoutService.js
...

const createNewWorkout = (newWorkout) => {
  const workoutToInsert = {
    ...newWorkout,
    id: uuid(),
    createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  const createdWorkout = Workout.createNewWorkout(workoutToInsert);
  return createdWorkout;
};

...

Una cosa che potrebbe andare male è l'inserimento nel database con il metodo Workout.createNewWorkout(). Mi piace incapsulare questa parte di codice in un blocco  try/catch per intercettare l'errore quando si verifica.

// In src/services/workoutService.js
...

const createNewWorkout = (newWorkout) => {
  const workoutToInsert = {
    ...newWorkout,
    id: uuid(),
    createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  try {
    const createdWorkout = Workout.createNewWorkout(workoutToInsert);
    return createdWorkout;
  } catch (error) {
    throw error;
  }
};

...

Ogni errore che si verifica all'interno del metodo Workout.createNewWorkout() verrà catturato all'interno del blocco catch. Lo reiteriamo semplicemente, in modo da poter modellare le nostre risposte successivamente all'interno del nostro controller.

Definiamo i nostri errori in Workout.js:

// In src/database/Workout.js
...

const createNewWorkout = (newWorkout) => {
  const isAlreadyAdded =
    DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
  if (isAlreadyAdded) {
    throw {
      status: 400,
      message: `Workout with the name '${newWorkout.name}' already exists`,
    };
  }
  try {
    DB.workouts.push(newWorkout);
    saveToDatabase(DB);
    return newWorkout;
  } catch (error) {
    throw { status: 500, message: error?.message || error };
  }
};

...

Come puoi vedere, un errore consiste di due parti, uno stato e un messaggio. Sto usando semplicemente la parola chiave throw per ritornare una struttura dati diversa da una stringa, che è richiesta se usi throw new Error().

Un piccolo svantaggio nell'inviare l'errore con throw è che non si ha uno stack trace. Normalmente questa generazione dell'errore dovrebbe essere gestita da una libreria di terze parti di nostra scelta (per esempio Mongoose se usi un database MongoDB). Per lo scopo di questo tutorial questo dovrebbe essere sufficiente.

Ora siamo in grado di generare e catturare gli errori nel servizio e nel livello di accesso dati. Possiamo passare ora al controller, catturare anche qui gli errori e rispondere di conseguenza.

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: {
          error:
            "One of the following keys is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
        },
      });
    return;
  }
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  // *** ADD ***
  try {
    const createdWorkout = workoutService.createNewWorkout(newWorkout);
    res.status(201).send({ status: "OK", data: createdWorkout });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

...

Puoi fare un test aggiungendo un allenamento con lo stesso nome due volte oppure non fornire una proprietà richiesta all'interno del corpo della tua richiesta. Dovresti ricevere i codici di errore HTTP corrispondenti, oltre ai messaggi di errore.

Per ricapitolare e passare al prossimo suggerimento, puoi copiare l'intera implementazione dei metodi nei seguenti file oppure puoi provare da solo:

// In src/controllers/workoutController.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
  try {
    const allWorkouts = workoutService.getAllWorkouts();
    res.send({ status: "OK", data: allWorkouts });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

const getOneWorkout = (req, res) => {
  const {
    params: { workoutId },
  } = req;
  if (!workoutId) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: { error: "Parameter ':workoutId' can not be empty" },
      });
  }
  try {
    const workout = workoutService.getOneWorkout(workoutId);
    res.send({ status: "OK", data: workout });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

const createNewWorkout = (req, res) => {
  const { body } = req;
  if (
    !body.name ||
    !body.mode ||
    !body.equipment ||
    !body.exercises ||
    !body.trainerTips
  ) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: {
          error:
            "One of the following keys is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
        },
      });
    return;
  }
  const newWorkout = {
    name: body.name,
    mode: body.mode,
    equipment: body.equipment,
    exercises: body.exercises,
    trainerTips: body.trainerTips,
  };
  try {
    const createdWorkout = workoutService.createNewWorkout(newWorkout);
    res.status(201).send({ status: "OK", data: createdWorkout });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

const updateOneWorkout = (req, res) => {
  const {
    body,
    params: { workoutId },
  } = req;
  if (!workoutId) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: { error: "Parameter ':workoutId' can not be empty" },
      });
  }
  try {
    const updatedWorkout = workoutService.updateOneWorkout(workoutId, body);
    res.send({ status: "OK", data: updatedWorkout });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

const deleteOneWorkout = (req, res) => {
  const {
    params: { workoutId },
  } = req;
  if (!workoutId) {
    res
      .status(400)
      .send({
        status: "FAILED",
        data: { error: "Parameter ':workoutId' can not be empty" },
      });
  }
  try {
    workoutService.deleteOneWorkout(workoutId);
    res.status(204).send({ status: "OK" });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
  getRecordsForWorkout,
};
// In src/services/workoutService.js
const { v4: uuid } = require("uuid");
const Workout = require("../database/Workout");

const getAllWorkouts = () => {
  try {
    const allWorkouts = Workout.getAllWorkouts();
    return allWorkouts;
  } catch (error) {
    throw error;
  }
};

const getOneWorkout = (workoutId) => {
  try {
    const workout = Workout.getOneWorkout(workoutId);
    return workout;
  } catch (error) {
    throw error;
  }
};

const createNewWorkout = (newWorkout) => {
  const workoutToInsert = {
    ...newWorkout,
    id: uuid(),
    createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
  };
  try {
    const createdWorkout = Workout.createNewWorkout(workoutToInsert);
    return createdWorkout;
  } catch (error) {
    throw error;
  }
};

const updateOneWorkout = (workoutId, changes) => {
  try {
    const updatedWorkout = Workout.updateOneWorkout(workoutId, changes);
    return updatedWorkout;
  } catch (error) {
    throw error;
  }
};

const deleteOneWorkout = (workoutId) => {
  try {
    Workout.deleteOneWorkout(workoutId);
  } catch (error) {
    throw error;
  }
};

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};
// In src/database/Workout.js
const DB = require("./db.json");
const { saveToDatabase } = require("./utils");

const getAllWorkouts = () => {
  try {
    return DB.workouts;
  } catch (error) {
    throw { status: 500, message: error };
  }
};

const getOneWorkout = (workoutId) => {
  try {
    const workout = DB.workouts.find((workout) => workout.id === workoutId);
    if (!workout) {
      throw {
        status: 400,
        message: `Can't find workout with the id '${workoutId}'`,
      };
    }
    return workout;
  } catch (error) {
    throw { status: error?.status || 500, message: error?.message || error };
  }
};

const createNewWorkout = (newWorkout) => {
  try {
    const isAlreadyAdded =
      DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
    if (isAlreadyAdded) {
      throw {
        status: 400,
        message: `Workout with the name '${newWorkout.name}' already exists`,
      };
    }
    DB.workouts.push(newWorkout);
    saveToDatabase(DB);
    return newWorkout;
  } catch (error) {
    throw { status: error?.status || 500, message: error?.message || error };
  }
};

const updateOneWorkout = (workoutId, changes) => {
  try {
    const isAlreadyAdded =
      DB.workouts.findIndex((workout) => workout.name === changes.name) > -1;
    if (isAlreadyAdded) {
      throw {
        status: 400,
        message: `Workout with the name '${changes.name}' already exists`,
      };
    }
    const indexForUpdate = DB.workouts.findIndex(
      (workout) => workout.id === workoutId
    );
    if (indexForUpdate === -1) {
      throw {
        status: 400,
        message: `Can't find workout with the id '${workoutId}'`,
      };
    }
    const updatedWorkout = {
      ...DB.workouts[indexForUpdate],
      ...changes,
      updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
    };
    DB.workouts[indexForUpdate] = updatedWorkout;
    saveToDatabase(DB);
    return updatedWorkout;
  } catch (error) {
    throw { status: error?.status || 500, message: error?.message || error };
  }
};

const deleteOneWorkout = (workoutId) => {
  try {
    const indexForDeletion = DB.workouts.findIndex(
      (workout) => workout.id === workoutId
    );
    if (indexForDeletion === -1) {
      throw {
        status: 400,
        message: `Can't find workout with the id '${workoutId}'`,
      };
    }
    DB.workouts.splice(indexForDeletion, 1);
    saveToDatabase(DB);
  } catch (error) {
    throw { status: error?.status || 500, message: error?.message || error };
  }
};

module.exports = {
  getAllWorkouts,
  createNewWorkout,
  getOneWorkout,
  updateOneWorkout,
  deleteOneWorkout,
};

Evitare verbi nei nomi degli endpoint

Non ha molto senso usare verbi all'interno degli endpoint, in effetti è piuttosto inutile. Generalmente ogni URL dovrebbe puntare verso una risorsa (ricorda l'esempio della scatola fatto in precedenza). Niente di più e niente di meno.

Usare un verbo all'interno di un URL mostra un certo comportamento che la risorsa stessa non può avere.

Abbiamo già implementato gli endpoint correttamente senza usare verbi all'interno degli URL, tuttavia diamo un'occhiata a come sarebbero i nostri URL se avessimo usato i verbi.

// Implementazione corrente (senza verbi)
GET "/api/v1/workouts" 
GET "/api/v1/workouts/:workoutId" 
POST "/api/v1/workouts" 
PATCH "/api/v1/workouts/:workoutId" 
DELETE "/api/v1/workouts/:workoutId"  

// Implementazione usando verbi 
GET "/api/v1/getAllWorkouts" 
GET "/api/v1/getWorkoutById/:workoutId" 
CREATE "/api/v1/createWorkout" 
PATCH "/api/v1/updateWorkout/:workoutId" 
DELETE "/api/v1/deleteWorkout/:workoutId"

Noti la differenza? Avere un URL completamente differente per ogni comportamento può generare confusione e inutile complessità piuttosto in fretta.

Immagina di avere 300 endpoint diversi. Usare un URL separato per ciascuno potrebbe essere un sovraccarico (compresa la documentazione) infernale.

Un'altra ragione che mi piacerebbe evidenziare per non usare verbi è che i verbi HTTP stessi indicano già l'azione (in lingua inglese - n.d.t.).

Cose come "GET /api/v1/getAllWorkouts" o "DELETE api/v1/deleteWorkout/workoutId" non sono necessarie.

Quando osservi la nostra implementazione corrente, diventa sempre più pulita in quanto stiamo usando solo due URL diversi e il comportamento effettivo è gestito tramite il verbo HTTP e il corrispondente payload della richiesta.

Ho sempre immaginato che il verbo HTTP descriva l'azione (quello che vorrei fare) e l'URL stesso (che punta verso una risorsa) l'obiettivo. "GET /api/v1/workouts" è anche più fluido come linguaggio umano (in inglese, naturalmente - n.d.t.).

Raggruppare assieme le risorse (annidamento logico)

Quando progetti la tua API, potrebbero esserci casi in cui disponi di risorse associate ad altre. È buona pratica raggrupparle in un unico endpoint e annidarle correttamente.

Considera che, nella nostra API, abbiamo anche un elenco di membri che si sono iscritti al nostro  CrossFit box ("box" è il nome per una palestra CrossFit). Per motivare i nostri iscritti tracciamo i risultati complessivi dei box per ogni allenamento.

Ad esempio, c'è un allenamento in cui devi eseguire un certo ordine di esercizi il più rapidamente possibile. Registriamo i tempi per tutti i membri per avere un elenco del tempo impiegato per ogni membro che ha completato questo allenamento.

Ora, al frontend serve un endpoint che risponda con tutti i risultati per uno specifico allenamento affinché possa essere visualizzato nell'interfaccia utente.

Gli allenamenti, i membri e i risultati sono conservati in posti diversi nel database. Quindi quello che ci serve è una scatola (i risultati - records) all'interno di un'altra scatola (gli allenamenti - workout), giusto?

L'URI per quell'endpoint sarà /api/v1/workouts/:workoutId/records. Questa è una buona pratica che consente un annidamento logico dell'URL. L'URL stesso non deve necessariamente rispecchiare la struttura del database.

Iniziamo l'implementazione dell'endpoint.

Prima aggiungiamo una nuova tabella nel file  db.json chiamata "members" (membri).  La piazziamo sotto "workouts" (allenamenti).

{
  "workouts": [ ...
  ],
  "members": [
    {
      "id": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
      "name": "Jason Miller",
      "gender": "male",
      "dateOfBirth": "23/04/1990",
      "email": "jason@mail.com",
      "password": "666349420ec497c1dc890c45179d44fb13220239325172af02d1fb6635922956"
    },
    {
      "id": "2b9130d4-47a7-4085-800e-0144f6a46059",
      "name": "Tiffany Brookston",
      "gender": "female",
      "dateOfBirth": "09/06/1996",
      "email": "tiffy@mail.com",
      "password": "8a1ea5669b749354110dcba3fac5546c16e6d0f73a37f35a84f6b0d7b3c22fcc"
    },
    {
      "id": "11817fb1-03a1-4b4a-8d27-854ac893cf41",
      "name": "Catrin Stevenson",
      "gender": "female",
      "dateOfBirth": "17/08/2001",
      "email": "catrin@mail.com",
      "password": "18eb2d6c5373c94c6d5d707650d02c3c06f33fac557c9cfb8cb1ee625a649ff3"
    },
    {
      "id": "6a89217b-7c28-4219-bd7f-af119c314159",
      "name": "Greg Bronson",
      "gender": "male",
      "dateOfBirth": "08/04/1993",
      "email": "greg@mail.com",
      "password": "a6dcde7eceb689142f21a1e30b5fdb868ec4cd25d5537d67ac7e8c7816b0e862"
    }
  ]
}

Prima che tu me lo chieda, sì, alle password è stato applicato un hash. 😉

Successivamente aggiungiamo alcuni  risultati ("records") sotto "members".

{
  "workouts": [ ...
  ],
  "members": [ ...
  ],
  "records": [
    {
      "id": "ad75d475-ac57-44f4-a02a-8f6def58ff56",
      "workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "record": "160 reps"
    },
    {
      "id": "0bff586f-2017-4526-9e52-fe3ea46d55ab",
      "workout": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
      "record": "7:23 minutes"
    },
    {
      "id": "365cc0bb-ba8f-41d3-bf82-83d041d38b82",
      "workout": "a24d2618-01d1-4682-9288-8de1343e53c7",
      "record": "358 reps"
    },
    {
      "id": "62251cfe-fdb6-4fa6-9a2d-c21be93ac78d",
      "workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "record": "145 reps"
    }
  ],
}

Per essere sicuro che tu abbia gli stessi allenamenti con gli stessi miei identificativi, copia anche la sezione "workouts":

{
  "workouts": [
    {
      "id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
      "name": "Tommy V",
      "mode": "For Time",
      "equipment": [
        "barbell",
        "rope"
      ],
      "exercises": [
        "21 thrusters",
        "12 rope climbs, 15 ft",
        "15 thrusters",
        "9 rope climbs, 15 ft",
        "9 thrusters",
        "6 rope climbs, 15 ft"
      ],
      "createdAt": "4/20/2022, 2:21:56 PM",
      "updatedAt": "4/20/2022, 2:21:56 PM",
      "trainerTips": [
        "Split the 21 thrusters as needed",
        "Try to do the 9 and 6 thrusters unbroken",
        "RX Weights: 115lb/75lb"
      ]
    },
    {
      "id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "name": "Dead Push-Ups",
      "mode": "AMRAP 10",
      "equipment": [
        "barbell"
      ],
      "exercises": [
        "15 deadlifts",
        "15 hand-release push-ups"
      ],
      "createdAt": "1/25/2022, 1:15:44 PM",
      "updatedAt": "3/10/2022, 8:21:56 AM",
      "trainerTips": [
        "Deadlifts are meant to be light and fast",
        "Try to aim for unbroken sets",
        "RX Weights: 135lb/95lb"
      ]
    },
    {
      "id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
      "name": "Heavy DT",
      "mode": "5 Rounds For Time",
      "equipment": [
        "barbell",
        "rope"
      ],
      "exercises": [
        "12 deadlifts",
        "9 hang power cleans",
        "6 push jerks"
      ],
      "createdAt": "11/20/2021, 5:39:07 PM",
      "updatedAt": "4/22/2022, 5:49:18 PM",
      "trainerTips": [
        "Aim for unbroken push jerks",
        "The first three rounds might feel terrible, but stick to it",
        "RX Weights: 205lb/145lb"
      ]
    },
    {
      "name": "Core Buster",
      "mode": "AMRAP 20",
      "equipment": [
        "rack",
        "barbell",
        "abmat"
      ],
      "exercises": [
        "15 toes to bars",
        "10 thrusters",
        "30 abmat sit-ups"
      ],
      "trainerTips": [
        "Split your toes to bars in two sets maximum",
        "Go unbroken on the thrusters",
        "Take the abmat sit-ups as a chance to normalize your breath"
      ],
      "id": "a24d2618-01d1-4682-9288-8de1343e53c7",
      "createdAt": "4/22/2022, 5:50:17 PM",
      "updatedAt": "4/22/2022, 5:50:17 PM"
    }
  ],
  "members": [ ...
  ],
  "records": [ ...
  ]
}

Va bene, prendiamoci qualche minuto per pensare all'implementazione.

Abbiamo una risorsa chiamata "workouts" (gli allenamenti) da una parte e un'altra chiamata "records" (i risultati) dall'altra.

Per continuare nella nostra architettura sarebbe consigliabile creare un altro controller, un altro servizio e un'altra collezione di metodi di database che sono responsabili per i risultati.

È molto probabile che dobbiamo implementare gli endpoint CRUD anche per i risultati, perché anch'essi dovrebbero essere aggiunti, aggiornati o eliminati in futuro. Ma questo non sarà il compito principale per ora.

Avremo anche bisogno di un router per  gestire le richieste specifiche per i risultati, ma non ne abbiamo bisogno al momento. Questa potrebbe essere una grande opportunità per te per implementare le operazioni CRUD per i record con le proprie rotte ed esercitarti un po'.

# Crea il controller per i risultati
touch src/controllers/recordController.js 

# Crea il servizio per i risultati 
touch src/services/recordService.js 

# Crea i metodi del database per i risultati
touch src/database/Record.js

Questo era facile. Passiamo oltre, partiamo dalla fine implementando i metodi del database.

// In src/database/Record.js
const DB = require("./db.json");

const getRecordForWorkout = (workoutId) => {
  try {
    const record = DB.records.filter((record) => record.workout === workoutId);
    if (!record) {
      throw {
        status: 400,
        message: `Can't find workout with the id '${workoutId}'`,
      };
    }
    return record;
  } catch (error) {
    throw { status: error?.status || 500, message: error?.message || error };
  }
};
module.exports = { getRecordForWorkout };

Piuttosto semplice, giusto? Filtriamo tutti i record che sono relativi all'allenamento il cui codice identificativo riceviamo come parametro di query.

Il prossimo è il servizio per i risultati:

// In src/services/recordService.js
const Record = require("../database/Record");

const getRecordForWorkout = (workoutId) => {
  try {
    const record = Record.getRecordForWorkout(workoutId);
    return record;
  } catch (error) {
    throw error;
  }
};
module.exports = { getRecordForWorkout };

Anche qui niente di nuovo.

Ora siamo in grado di creare una nuova rotta nel router e dirigere la richiesta verso il servizio per i risultati.

// In src/v1/routes/workoutRoutes.js
const express = require("express");
const workoutController = require("../../controllers/workoutController");
// *** AGGIUNGI ***
const recordController = require("../../controllers/recordController");

const router = express.Router();

router.get("/", workoutController.getAllWorkouts);

router.get("/:workoutId", workoutController.getOneWorkout);

// *** AGGIUNGI ***
router.get("/:workoutId/records", recordController.getRecordForWorkout);

router.post("/", workoutController.createNewWorkout);

router.patch("/:workoutId", workoutController.updateOneWorkout);

router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

Grande! Facciamo un test con il browser.

Per prima cosa recuperiamo tutti gli allenamenti per ricavare l'id dell'allenamento.

Bildschirmfoto-2022-04-30-um-15.36.48

Vediamo se possiamo recuperare tutti i record per quell'id:

Bildschirmfoto-2022-04-30-um-15.36.32

Come puoi vedere, l'annidamento logico ha senso se hai risorse che possono essere in relazione tra loro. In teoria puoi annidare in profondità a piacimento, ma nella pratica è meglio non superare i tre livelli di annidamento al massimo.

Se vuoi annidare più in profondità, potresti fare una piccola modifica al database. Ti mostro un piccolo esempio.

Immagina che al frontend serva anche un endpoint per ottenere informazioni relative a quale membro esattamente detenga il record corrente e vuole ricevere metadati al proposito.

Naturalmente potremmo implementare l'URI seguente:

GET /api/v1/workouts/:workoutId/records/members/:memberId

L'endpoint ora diventa meno gestibile mano a mano che aggiungiamo livelli di annidamento. Pertanto è buona pratica inserire l'URI per ricevere informazioni su un dato membro direttamente nel record.

Considera quanto segue all'interno del database:

{
  "workouts": [ ...
  ],
  "members": [ ...
  ],
  "records": [ ... {
      "id": "ad75d475-ac57-44f4-a02a-8f6def58ff56",
      "workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "record": "160 reps",
      "memberId": "11817fb1-03a1-4b4a-8d27-854ac893cf41",
      "member": "/members/:memberId"
    },
  ]
}

Come puoi vedere abbiamo aggiunto due proprietà: "memberId" e "member" per i nostri record all'interno del database. Questo presenta il grosso vantaggio che non dobbiamo annidare più in profondità il nostro endpoint esistente.

Il frontend deve semplicemente chiamare GET /api/v1/workouts/:workoutId/records per ricevere automaticamente tutti i risultati che sono relativi a questo allenamento.

Inoltre ottiene l'id del membro e l'endpoint per recuperare le informazioni su quel membro. Quindi, abbiamo evitato l'annidamento più profondo del nostro endpoint.

Naturalmente, questo funziona solo se siamo in grado di gestire le richieste a "/members/:memberId" 😁 Sembra un'ottima opportunità di formazione per te per implementare questa situazione!

Integrare filtri, ordinamento e paginazione

In questo momento siamo in grado di eseguire alcune operazioni con la nostra API. Questo è un grande progresso, ma c'è di più.

Durante le ultime sezioni ci siamo concentrati sul miglioramento della nostra esperienza di sviluppatore e su come sia possibile interagire con la nostra API. Ma le prestazioni complessive della nostra API sono un altro fattore chiave su cui dovremmo lavorare.

Ecco perché anche l'integrazione di filtri, ordinamento e paginazione è un fattore essenziale nella mia lista.

Immagina di avere 2.000 allenamenti, 450 record e 500 membri memorizzati nel nostro database. Quando chiamiamo il nostro endpoint per ottenere tutti gli allenamenti, non vogliamo inviare tutti i 2.000  contemporaneamente. Questa sarà una risposta molto lenta ovviamente, o farà crollare i nostri sistemi (forse con 200.000 😁).

Questo è il motivo per cui il filtro e la paginazione sono importanti. Il filtro, come dice già il nome, è utile perché ci consente di ottenere dati specifici da tutta la nostra collezione. Ad esempio tutti gli allenamenti che hanno la modalità "For Time".

La paginazione è un altro meccanismo per suddividere la nostra intera raccolta di allenamenti in più "pagine" in cui ogni pagina è composta solo da venti allenamenti, ad esempio. Questa tecnica ci aiuta ad assicurarci di non inviare più di venti allenamenti contemporaneamente con la nostra risposta al client.

L'ordinamento può essere un compito complesso. Quindi è più efficace farlo nella nostra API e inviare i dati ordinati al client.

Cominciamo con l'integrazione di alcuni meccanismi di filtro nella nostra API. Miglioreremo il nostro endpoint che invia tutti gli allenamenti accettando parametri per il filtro. Normalmente in una richiesta GET aggiungiamo i criteri di filtro come parametro di query.

Il nostro nuovo URI sarà simile a questo, quando vorremmo ottenere solo gli allenamenti che sono nella modalità di "AMRAP" (As Many Rounds As Possible - tante ripetizioni quante possibile): /api/v1/workouts?mode=amrap.

Per renderlo più divertente dobbiamo aggiungere altri allenamenti. Incolla questi allenamenti nella tua raccolta "workouts" all'interno di db.json:

{
  "name": "Jumping (Not) Made Easy",
  "mode": "AMRAP 12",
  "equipment": [
    "jump rope"
  ],
  "exercises": [
    "10 burpees",
    "25 double-unders"
  ],
  "trainerTips": [
    "Scale to do 50 single-unders, if double-unders are too difficult"
  ],
  "id": "8f8318f8-b869-4e9d-bb78-88010193563a",
  "createdAt": "4/25/2022, 2:45:28 PM",
  "updatedAt": "4/25/2022, 2:45:28 PM"
},
{
  "name": "Burpee Meters",
  "mode": "3 Rounds For Time",
  "equipment": [
    "Row Erg"
  ],
  "exercises": [
    "Row 500 meters",
    "21 burpees",
    "Run 400 meters",
    "Rest 3 minutes"
  ],
  "trainerTips": [
    "Go hard",
    "Note your time after the first run",
    "Try to hold your pace"
  ],
  "id": "0a5948af-5185-4266-8c4b-818889657e9d",
  "createdAt": "4/25/2022, 2:48:53 PM",
  "updatedAt": "4/25/2022, 2:48:53 PM"
},
{
  "name": "Dumbbell Rower",
  "mode": "AMRAP 15",
  "equipment": [
    "Dumbbell"
  ],
  "exercises": [
    "15 dumbbell rows, left arm",
    "15 dumbbell rows, right arm",
    "50-ft handstand walk"
  ],
  "trainerTips": [
    "RX weights for women: 35-lb",
    "RX weights for men: 50-lb"
  ],
  "id": "3dc53bc8-27b8-4773-b85d-89f0a354d437",
  "createdAt": "4/25/2022, 2:56:03 PM",
  "updatedAt": "4/25/2022, 2:56:03 PM"
}

Tutto quello che dobbiamo fare è accettare e gestire i parametri di query. Il nostro controller che gestisce gli allenamenti sarà il posto giusto da cui partire:

// In src/controllers/workoutController.js
...

const getAllWorkouts = (req, res) => {
  // *** ADD ***
  const { mode } = req.query;
  try {
    // *** ADD ***
    const allWorkouts = workoutService.getAllWorkouts({ mode });
    res.send({ status: "OK", data: allWorkouts });
  } catch (error) {
    res
      .status(error?.status || 500)
      .send({ status: "FAILED", data: { error: error?.message || error } });
  }
};

...

Estraiamo  "mode" dall'oggetto  req.query e ne passiamo il valore a workoutService.getAllWorkouts. Questo valore sarà un oggetto che contiene tutti i parametri di filtro.

Uso la sintassi scorciatoia qui, per creare una chiave chiamata mode all'interno dell'oggetto, il cui valore è qualunque cosa sia associata a req.query.mode. Potrebbe essere sia un valore truthy oppure undefined se non esiste un parametro di query chiamato mode. Possiamo estendere questo oggetto con ulteriori parametri di filtro che vorremmo accettare.

Nel servizio workout, passalo al tuo metodo di database:

// In src/services/workoutService.js
...

const getAllWorkouts = (filterParams) => {
  try {
    // *** AGGIUNGI ***
    const allWorkouts = Workout.getAllWorkouts(filterParams);
    return allWorkouts;
  } catch (error) {
    throw error;
  }
};

...

Ora possiamo usare il nostro metodo di database e applicare il filtro:

// In src/database/Workout.js
...

const getAllWorkouts = (filterParams) => {
  try {
    let workouts = DB.workouts;
    if (filterParams.mode) {
      return DB.workouts.filter((workout) =>
        workout.mode.toLowerCase().includes(filterParams.mode)
      );
    }
    // Altre istruzioni if vanno qui per gestire parametri differenti
    return workouts;
  } catch (error) {
    throw { status: 500, message: error };
  }
};

...

Piuttosto semplice, giusto? Tutto quello che dobbiamo fare qui è verificare se abbiamo effettivamente un valore truthy per la chiave mode all'interno di filterParams. Se questo è vero, filtriamo tutti gli allenamenti che hanno lo stesso valore per mode, altrimenti non c'è un parametro di query con quel nome e ritorniamo tutti gli allenamenti in quanto non è necessario filtrarli.

Abbiamo dichiarato la variabile workouts con let poiché quando aggiungiamo ulteriori istruzioni if per diversi filtri possiamo sovrascrivere workouts e concatenare i filtri.

All'interno del tuo browser puoi visitare localhost:3000/api/v1/workouts?mode=amrap e riceverai tutti gli allenamenti nel database che hanno la proprietà "mode" uguale a  "AMRAP":

Bildschirmfoto-2022-04-30-um-15.48.57

Se ometti il parametro di query, dovresti ottenere tutti gli allenamenti come prima. Puoi provare ancora aggiungendo "for%20time" come valore per il parametro mode (ricorda --> "%20" significa "spazio") e ora dovresti ricevere tutti gli allenamenti che hanno il valore di mode uguale a "For Time", se esistono nel database.

Se digiti un valore che non è nel database, dovresti ricevere un array vuoto.

I parametri per ordinare e la paginazione seguono la stessa filosofia. Diamo un'occhiata ad alcune funzionalità che potremmo implementare:

  • Ricevere tutti gli allenamenti che richiedono un barbell (bilanciere): /api/v1/workouts?equipment=barbell
  • Ottenere solo 5 allenamenti: /api/v1/workouts?length=5
  • Quando usiamo la paginazione ricevere la seconda pagina: /api/v1/workouts?page=2
  • Ordinare gli allenamenti nella risposta in ordine discendente, per data di creazione: /api/v1/workouts?sort=-createdAt
  • Puoi anche combinare i parametri, per ottenere gli ultimi 10 allenamenti aggiornati, per esempio: /api/v1/workouts?sort=-updatedAt&length=10

Usare cache dei dati per migliorare le prestazioni

Anche utilizzare una cache per i dati è una ottima pratica per migliorare l'esperienza complessiva e le prestazioni della nostra API.

Ha molto senso usare una cache dalla quale servire i dati, quando i dati provengono da una risorsa richiesta di frequente e/o quando l'interrogazione di quei dati dal database è un'operazione molto pesante e potrebbe richiedere più secondi.

Puoi conservare questo tipo di dati all'interno della tua cache e servirli da lì invece di interrogare ogni volta il database per ottenere i dati.

Una cosa importante che devi tenere a mente è che quando si servono dati da una cache, potrebbero essere diventati obsoleti. Quindi devi assicurarti che i dati all'interno della cache siano sempre aggiornati.

Ci sono molte soluzioni diverse a disposizione. Un esempio appropriato è usare redis o il middleware di express apicache.

Preferirei usare apicache, ma se vuoi usare Redis, consiglio caldamente che tu dia un'occhiata alla loro ottima documentazione.

Pensiamo un secondo a uno scenario nella nostra API nel quale una cache avrebbe senso. Penso che le richieste per ottenere tutti gli allenamenti potrebbero essere esaudite con efficacia all'interno della nostra cache.

Per prima cosa installiamo il nostro middleware:

npm i apicache

Ora dobbiamo importarlo nel router che gestisce gli allenamenti e configurarlo.

// In src/v1/routes/workoutRoutes.js
const express = require("express");
// *** AGGIUNGI ***
const apicache = require("apicache");
const workoutController = require("../../controllers/workoutController");
const recordController = require("../../controllers/recordController");

const router = express.Router();
// *** AGGIUNGI ***
const cache = apicache.middleware;

// *** AGGIUNGI ***
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

router.get("/:workoutId", workoutController.getOneWorkout);

router.get("/:workoutId/records", recordController.getRecordForWorkout);

router.post("/", workoutController.createNewWorkout);

router.patch("/:workoutId", workoutController.updateOneWorkout);

router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

È piuttosto semplice impostarla, vero? Possiamo definire una nuova cache chiamando apicache.middleware e usarla come middleware all'interno della nostra rotta GET. Devi solo inserirla come parametro tra il percorso effettivo e il controller che gestisce gli allenamenti.

Lì dentro puoi definire per quanto tempo i tuoi dati dovrebbero rimanere nella cache. Per le esigenze di questo tutorial ho scelto due minuti. Il tempo dipende dalla velocità o dalla frequenza con cui cambiano i dati all'interno della cache.

Ora proviamo!

All'interno di Postman o di un altro client HTTP di tua scelta, definisci una nuova richiesta che ottiene tutti gli allenamenti. Fino ad ora l'ho fatto dal browser, ma vorrei meglio visualizzare il tempo di risposta per te. Ecco la ragione per la quale ora sto richiedendo la risorsa tramite Postman.

Eseguiamo la nostra richiesta per la prima volta:

Bildschirmfoto-2022-04-26-um-15.36.46-1

Come puoi vedere la nostra API ha risposto in 22.93 ms. Una volta che la nostra cache viene nuovamente svuotata (dopo due minuti) deve essere riempita nuovamente. Questo succede con la nostra prima richiesta.

Quindi nel caso qui sopra, i dati NON sono stati serviti dalla nostra cache. Sono stati presi  "normalmente" dal database e sono stati inseriti nella cache.

Ora con la nostra seconda richiesta riceviamo i dati con un tempo di risposta minore, in quanto i dati sono serviti direttamente dalla cache:

Bildschirmfoto-2022-04-26-um-15.36.59-1

Siamo stati in grado di servire i dati in un terzo del tempo impiegato nella nostra richiesta precedente! Tutto grazie alla nostra cache.

Nel nostro esempio abbiamo usato la cache con una sola rotta, ma puoi anche utilizzarla su tutte le rotte con un'implementazione come questa:

// In src/index.js
const express = require("express");
const bodyParser = require("body-parser");
// *** AGGIUNGI ***
const apicache = require("apicache");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
// *** AGGIUNGI ***
const cache = apicache.middleware;
const PORT = process.env.PORT || 3000;

app.use(bodyParser.json());
// *** AGGIUNGI ***
app.use(cache("2 minutes"));
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
  console.log(`API is listening on port ${PORT}`);
});

C'è una cosa importante che vorrei far notare qui per quanto riguarda la cache. Anche se sembra che ti risolva molti problemi, puoi anche portarne altri alla tua applicazione.

Alcune cose delle quali devi essere a conoscenza quando utilizzi una cache:

  • devi sempre assicurarti che i dati all'interno della cache siano aggiornati in quanto tu non vuoi servire dati obsoleti
  • quando la prima richiesta viene elaborata e la cache sta per essere riempita e ci sono ulteriori richieste in arrivo, devi decidere se ritardarle e servire i dati dalla cache oppure se esse riceveranno i dati direttamente dal database come nella prima richiesta
  • è un altro componente da inserire all'interno della tua infrastruttura, se stai scegliendo una cache distribuita come Redis (quindi devi domandarti se ha veramente senso usarla)

Ecco cosa faccio io in genere:

Vorrei partire il più semplice e pulito possibile con qualsiasi cosa stia creando. Lo stesso vale per le API.

Quando inizio la creazione di un'API e non ci sono ragioni particolari per usare una cache immediatamente, la tengo da parte e vedo cosa succede nel tempo. Se si verificano ragioni per le quali usare una cache, la implemento.

Buone pratiche di sicurezza

Wow! Questo è stato un bel viaggio finora. Abbiamo toccato molti punti importanti e abbiamo esteso la nostra API di conseguenza.

Abbiamo parlato delle migliori pratiche per accrescere l'usabilità e le prestazioni della nostra API. Anche la sicurezza è un fattore chiave per l'API. Puoi creare l'API migliore, ma quando si tratta di una parte di software vulnerabile in esecuzione in un server diventa inutile e pericolosa.

La prima indispensabile misura di sicurezza da includere è l'uso di SSL/TLS in quanto al giorno d'oggi è lo standard per quanto riguarda la comunicazione su internet. È ancora più importante dove i dati privati vengono scambiati tra il client e l'API.

Se hai risorse che dovrebbero essere disponibili solo a utenti autenticati, dovresti proteggerle con una verifica di autenticazione.

In Express, per esempio, puoi implementarla come middleware come abbiamo fatto per la nostra cache per determinate rotte e verificare prima se la richiesta è autenticata per accedere a una risorsa.

Potrebbero esserci risorse o interazioni con la nostra API che non vogliamo vengano richieste da qualunque utente. In questo caso dovresti impostare un sistema di ruoli per i tuoi utenti. Pertanto devi aggiungere altra logica di verifica per quella rotta e validare se l'utente ha il privilegio di accedere alla risorsa.

I ruoli utenti avrebbero senso anche nel nostro caso d'uso quando vogliamo che solo utenti specifici (come gli allenatori) possano creare, aggiornare ed eliminare gli allenamenti e i risultati. La lettura può essere aperta a tutti (anche ai membri "normali").

Si può gestire la cosa tramite un altro middleware che usiamo per le rotte che vogliamo proteggere. Per esempio la nostra richiesta POST per /api/v1/workouts per creare un nuovo allenamento.

All'interno del primo middleware verificheremo se l'utente è autenticato. In caso positivo passeremo al middleware successivo, vale a dire la verifica del ruolo dell'utente. Se l'utente ha il ruolo appropriato per accedere alla risorsa la richiesta viene passata al controller corrispondente.

All'interno del gestore delle rotte il codice sarà come questo:

// In src/v1/routes/workoutRoutes.js
...

// Middleware personalizzato
const authenticate = require("../../middlewares/authenticate");
const authorize = require("../../middlewares/authorize");

router.post("/", authenticate, authorize, workoutController.createNewWorkout);

...

Per ulteriori informazioni e migliori pratiche su questo argomento, suggerisco di leggere questo articolo (in lingua inglese).

Documentare correttamente la tua API

So che la documentazione non è esattamente l'attività preferita dagli sviluppatori, ma è una cosa necessaria da fare. Specialmente quando si parla di API.

Qualcuno dice che:

"Un'API è tanto buona quanto la sua documentazione"

Credo che ci sia molto di vero in questa affermazione in quanto se un'API non è ben documentata non può essere usata adeguatamente, pertanto diventa inutile. La documentazione aiuta anche a rendere la vita degli sviluppatori molto più facile.

Ricorda sempre che la documentazione in genere è la prima interazione che gli utilizzatori hanno con la tua API. Più velocemente gli utenti saranno in grado di capire la documentazione, più velocemente potranno usare la tua API.

Pertanto è nostro compito implementare una documentazione esauriente e precisa. Ci sono degli ottimi strumenti a disposizione che ci faciliteranno la vita.

Come in altri campi dell'informatica, c'è una sorta di standard per documentare le API chiamata OpenAPI Specification (Specifiche OpenAPI).

Vediamo come possiamo creare una qualche documentazione che giustifichi quelle specifiche. Useremo i pacchetti swagger-ui-express e swagger-jsdoc per farlo. In un attimo resterai stupito di quanto sia fantastico!

Innanzitutto, impostiamo la struttura base per la nostra documentazione. Poiché abbiamo pianificato di avere diverse versioni della nostra API, anche i documenti saranno leggermente diversi. Questo è il motivo per cui vorrei definire il nostro file swagger per lanciare la nostra documentazione all'interno della cartella della versione corrispondente.

# Installa i pacchetti npm richiesti 
npm i swagger-jsdoc swagger-ui-express 

# Crea un nuovo file per impostare i documenti swagger 
touch src/v1/swagger.js
// In src/v1/swagger.js
const swaggerJSDoc = require("swagger-jsdoc");
const swaggerUi = require("swagger-ui-express");

// Meta Informazioni base sulla nostra API
const options = {
  definition: {
    openapi: "3.0.0",
    info: { title: "Crossfit WOD API", version: "1.0.0" },
  },
  apis: ["./src/v1/routes/workoutRoutes.js", "./src/database/Workout.js"],
};

// Documentazione in formato JSON
const swaggerSpec = swaggerJSDoc(options);

// Funzione per impostare la nostra documentazione
const swaggerDocs = (app, port) => {
  // Gestore della rotta per visitare la documentazione
  app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
  // Rendiamo disponibile la nostra documentazione in formato JSON
  app.get("/api/v1/docs.json", (req, res) => {
    res.setHeader("Content-Type", "application/json");
    res.send(swaggerSpec);
  });
  console.log(
    `Version 1 Docs are available on http://localhost:${port}/api/v1/docs`
  );
};

module.exports = { swaggerDocs };

L'impostazione è piuttosto semplice. Abbiamo definito qualche metadato base per la nostra API, creata la documentazione in formato JSON e creata una funzione che rende disponibile la nostra documentazione.

Per controllare se tutto è a posto e in esecuzione, registriamo un semplice messaggio alla console che indica dove possiamo trovare la documentazione.

Questa sarà la funzione che useremo nel file radice (index.js) dove abbiamo creato il server Express per assicurarci che anche la documentazione sia caricata.

// In src/index.js
const express = require("express");
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");
// *** AGGIUNGI ***
const { swaggerDocs: V1SwaggerDocs } = require("./v1/swagger");

const app = express();
const PORT = process.env.PORT || 3000;

app.use(bodyParser.json());
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
  console.log(`API is listening on port ${PORT}`);
  /// *** AGGIUNGI ***
  V1SwaggerDocs(app, PORT);
});

Ora dovresti vedere quanto segue all'interno del terminale dove è in esecuzione il tuo server di sviluppo:

Bildschirmfoto-2022-04-28-um-20.23.51-1

Quando visiti  localhost:3000/api/v1/docs nel browser, dovresti già vedere la nostra pagina di documentazione:

Bildschirmfoto-2022-04-28-um-20.25.00-1

Ogni volta mi stupisco di quanto funzioni bene. Ora, la struttura di base è configurata e possiamo iniziare a implementare la documentazione per i nostri endpoint. Andiamo!

Quando dai un'occhiata al contenuto della chiave options.apis nel file swagger.js, vedrai che abbiamo incluso il percorso alle nostre rotte per l'allenamento e per il file degli allenamenti all'interno della cartella database. Questa è la cosa più importante da impostare per fare in modo che la magia si verifichi.

Avendo definito questi file all'interno delle opzioni swagger ci consente di usare commenti che fanno riferimento a OpenAPI e ad avere la sintassi come nei file yaml, necessaria per l'impostazione della nostra documentazione.

Ora siamo pronti a creare la documentazione per il nostro primo endpoint! Facciamolo subito.

// In src/v1/routes/workoutRoutes.js
...

/**
 * @openapi
 * /api/v1/workouts:
 *   get:
 *     tags:
 *       - Workouts
 *     responses:
 *       200:
 *         description: OK
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 status:
 *                   type: string
 *                   example: OK
 *                 data:
 *                   type: array 
 *                   items: 
 *                     type: object
 */
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

...

Questo è praticamente quanto serve per aggiungere un endpoint alla nostra documentazione swagger. Puoi vedere tutte le specifiche per descrivere un endpoint nella loro ottima documentazione.

Quando ricarichi la tua pagina di documentazione dovresti vedere quanto segue:

Bildschirmfoto-2022-04-29-um-07.21.51-1

Questo dovrebbe esserti piuttosto familiare se hai già lavorato con API che hanno una documentazione secondo le specifiche OpenAPI. Questa è la vista dove sono elencati tutti gli endpoint e puoi estendere ciascuno di essi per ottenere maggiori informazioni.

Bildschirmfoto-2022-04-29-um-07.41.46-1

Se osservi attentamente la nostra risposta, vedrai che non abbiamo definito il corretto valore di ritorno perché stiamo semplicemente dicendo che la nostra proprietà "data" sarà un array di oggetti vuoti.

Ecco quando entrano in gioco gli schemi.

// In src/databse/Workout.js
...

/**
 * @openapi
 * components:
 *   schemas:
 *     Workout:
 *       type: object
 *       properties:
 *         id: 
 *           type: string
 *           example: 61dbae02-c147-4e28-863c-db7bd402b2d6
 *         name: 
 *           type: string
 *           example: Tommy V  
 *         mode:
 *           type: string
 *           example: For Time
 *         equipment:
 *           type: array
 *           items:
 *             type: string
 *           example: ["barbell", "rope"]
 *         exercises:
 *           type: array
 *           items:
 *             type: string
 *           example: ["21 thrusters", "12 rope climbs, 15 ft", "15 thrusters", "9 rope climbs, 15 ft", "9 thrusters", "6 rope climbs, 15 ft"]
 *         createdAt:
 *           type: string
 *           example: 4/20/2022, 2:21:56 PM
 *         updatedAt: 
 *           type: string
 *           example: 4/20/2022, 2:21:56 PM
 *         trainerTips:
 *           type: array
 *           items:
 *             type: string
 *           example: ["Split the 21 thrusters as needed", "Try to do the 9 and 6 thrusters unbroken", "RX Weights: 115lb/75lb"]
 */

...

Nell'esempio qui sopra abbiamo creato il nostro primo schema. Tipicamente questa definizione sarà all'interno del nostro file schema o modello dove hai definito i modelli del database.

Come puoi vedere è piuttosto semplice. Abbiamo definito tutte le proprietà che compongono un allenamento incluso il tipo e un esempio.

Puoi visitare la nostra pagina di documentazione nuovamente e riceverai un'altra sezione che contiene gli schemi.

Bildschirmfoto-2022-04-29-um-07.29.49-1

Questo schema può ora essere referenziato nella risposta del nostro endpoint.

// In src/v1/routes/workoutRoutes.js
...

/**
 * @openapi
 * /api/v1/workouts:
 *   get:
 *     tags:
 *       - Workouts
 *     responses:
 *       200:
 *         description: OK
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 status:
 *                   type: string
 *                   example: OK
 *                 data:
 *                   type: array 
 *                   items: 
 *                     $ref: "#/components/schemas/Workout"
 */
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

...

Guarda attentamente al fondo della riga di commento sotto "items". Stiamo usando "$ref" per creare un riferimento e stiamo referenziando il percorso del nostro schema che abbiamo definito all'interno del file degli allenamenti.

Ora siamo in grado di mostrare un allenamento completo nella nostra risposta.

Bildschirmfoto-2022-04-29-um-07.44.12-1

Abbastanza bello, vero? Potresti pensare che "digitare questi commenti a mano può essere un compito noioso".

Questo potrebbe essere vero, ma pensala in questo modo. Quei commenti che si trovano all'interno del tuo codice sono anche un'ottima documentazione per te stesso come sviluppatore dell'API. Non è necessario consultare sempre la documentazione quando si desidera conoscere le informazioni su un endpoint specifico. Puoi semplicemente cercarlo in un posto all'interno del tuo codice sorgente.

La documentazione degli endpoint ti aiuta anche a comprenderli meglio e ti "costringe" a pensare a qualsiasi cosa che potresti aver dimenticato di implementare.

Come vedi ho dimenticato davvero qualcosa. Mancano ancora le possibili risposte di errore e i parametri di query!

Risolviamolo:

// In src/v1/routes/workoutRoutes.js
...

/**
 * @openapi
 * /api/v1/workouts:
 *   get:
 *     tags:
 *       - Workouts
 *     parameters:
 *       - in: query
 *         name: mode
 *         schema:
 *           type: string
 *         description: The mode of a workout
 *     responses:
 *       200:
 *         description: OK
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 status:
 *                   type: string
 *                   example: OK
 *                 data:
 *                   type: array 
 *                   items: 
 *                     $ref: "#/components/schemas/Workout"
 *       5XX:
 *         description: FAILED
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 status: 
 *                   type: string
 *                   example: FAILED
 *                 data:
 *                   type: object
 *                   properties:
 *                     error:
 *                       type: string 
 *                       example: "Some error message"
 */
router.get("/", cache("2 minutes"),  workoutController.getAllWorkouts);

...

Quando guardi la parte superiore del nostro commento sotto "tags", puoi vedere che ho aggiunto un'altra chiave chiamata "parameters", dove ho definito il nostro parametro di ricerca per il filtro.

La nostra documentazione ora lo visualizza correttamente:

Bildschirmfoto-2022-04-29-um-08.03.00-1

Per documentare un possibile caso di errore, a questo punto stiamo generando solo un errore 5XX. Quindi sotto "responses" puoi vedere che ho anche definito un'altra documentazione per questo..

La nostra pagina di documentazione ora è come questa:

Bildschirmfoto-2022-04-29-um-08.04.44-2

Fantastico! Abbiamo appena creato la documentazione completa per un endpoint. Ti consiglio vivamente di implementare il resto degli endpoint da solo per acquisire pratica. Imparerai molto mentre lo fai!

Come potresti aver visto, documentare la tua API non deve essere sempre un mal di testa. Penso che gli strumenti che ti ho presentato riducano il tuo sforzo complessivo e impostare tutto sia piuttosto semplice.

Quindi possiamo concentrarci sulla cosa importante, la documentazione stessa. A mio parere, la documentazione di swagger/OpenAPI è molto buona e ci sono molti ottimi esempi su internet.

Non avere una documentazione a causa del troppo lavoro "extra" non dovrebbe più essere una scusa.

Conclusione

Finalmente, è stata una corsa piuttosto divertente. Mi è davvero piaciuto scrivere questo articolo per te e ho anche imparato molto.

Potrebbero esserci migliori pratiche che sono importanti mentre altre potrebbero non essere applicabili alla tua situazione attuale. Va bene, perché come ho detto prima, è responsabilità di ogni sviluppatore scegliere le migliori pratiche che possono essere applicate alla propria situazione attuale.

Ho fatto del mio meglio per mettere insieme tutte quelle che ho attuato fino ad ora creando la nostra API lungo il percorso. Mi sono davvero divertito!

Mi piacerebbe ricevere un feedback di qualsiasi tipo. Se c'è qualcosa che vorresti dirmi (buona o cattiva), non esitare a contattarmi:

Ecco il mio Instagram (puoi anche seguire il mio percorso di sviluppatore di software)

Ci vediamo alla prossima!