Implementare le operazioni CRUD con MongoDB, Express e TypeScript

LT
Luca Terribili
Autore

Nei tutorial precedenti abbiamo preparato l'ambiente di sviluppo, configurato Node.js con TypeScript, organizzato il backend Express e predisposto la connessione a MongoDB. Abbiamo anche deciso di eseguire il database in un container Docker, così da avere un ambiente locale indipendente da servizi esterni.

Adesso è il momento di mettere insieme questi componenti per costruire qualcosa di concreto. Realizzeremo una piccola API REST per gestire un catalogo di prodotti. Non sarà un'applicazione completa di e-commerce, perché prima di arrivare a gestire pagamenti e ordini è utile imparare a salvare e recuperare correttamente qualche documento. Ogni tanto conviene costruire le fondamenta prima del tetto.

Il nostro obiettivo è implementare le quattro operazioni fondamentali che vengono comunemente indicate con l'acronimo CRUD: Create, Read, Update e Delete. Attraverso queste operazioni potremo creare un prodotto, consultare il catalogo, modificare i dati di un prodotto esistente ed eliminarlo.

Useremo il driver ufficiale mongodb, senza introdurre Mongoose. In questo modo vedremo direttamente come si lavora con le collection, come MongoDB identifica i documenti e come si costruiscono le query. In seguito potremo confrontare questa implementazione con una basata su Mongoose e valutare quali vantaggi offre un livello di astrazione aggiuntivo.

Cosa sono le operazioni CRUD

CRUD è un acronimo che riassume le quattro operazioni essenziali per gestire i dati persistenti di un'applicazione. Non è una tecnologia specifica e non è una caratteristica esclusiva di MongoDB: lo stesso concetto si applica ai database relazionali, ai database documentali e a molti altri sistemi di archiviazione.

Create significa creare un nuovo elemento. Nel nostro caso, quando un client invia una richiesta HTTP POST con il nome e il prezzo di un prodotto, il backend valida i dati e li salva in MongoDB.

Read indica la lettura dei dati. Utilizzeremo richieste GET per ottenere l'elenco dei prodotti oppure recuperare un singolo prodotto attraverso il suo identificativo.

Update serve a modificare un elemento già esistente. Implementeremo una richiesta PATCH che permetterà di aggiornare soltanto alcuni campi, senza dover inviare nuovamente l'intero documento.

Delete, infine, elimina un elemento. Riceveremo una richiesta DELETE contenente l'identificativo del prodotto e chiederemo a MongoDB di rimuovere il documento corrispondente.

Nel mondo HTTP queste operazioni vengono normalmente associate ai metodi POST, GET, PATCH e DELETE. Non è una corrispondenza obbligatoria in ogni possibile API, ma è una convenzione diffusa e comprensibile per chi utilizza servizi REST.

Preparare il database MongoDB

Prima di scrivere codice, verifichiamo che MongoDB sia disponibile. Nel nostro progetto utilizziamo Docker Compose, quindi possiamo controllare lo stato del container dalla cartella principale del progetto.

docker compose ps

Se il container non è avviato, eseguiamo:

docker compose up -d

La configurazione Docker utilizzata nei tutorial precedenti pubblica la porta 27017 sull'host e conserva i dati in un volume. Questo significa che il backend, eseguito direttamente su Fedora, può collegarsi a MongoDB attraverso 127.0.0.1:27017, senza dover essere inserito a sua volta in un container.

Nel file .env manteniamo queste variabili:

PORT=3000
MONGODB_URI=mongodb://devuser:devpassword@127.0.0.1:27017/?authSource=admin
MONGODB_DB=node_study

La stringa di connessione contiene le credenziali dell'utente MongoDB e l'indirizzo del servizio. Il nome node_study identifica invece il database utilizzato dalla nostra applicazione.

Non dobbiamo creare manualmente il database tramite un'interfaccia grafica. MongoDB può crearlo quando inseriamo il primo documento. La collection products verrà quindi creata automaticamente dalla prima operazione di inserimento, se ancora non esiste.

Le credenziali mostrate sono adatte soltanto al nostro ambiente locale di sviluppo. In un'applicazione distribuita su un server dovremo utilizzare credenziali robuste e una configurazione appropriata per l'ambiente di esecuzione.

Organizzare la struttura del progetto

Manteniamo la struttura introdotta nei tutorial precedenti e aggiungiamo un file dedicato alle rotte dei prodotti.

node-study/
├── src/
│   ├── config/
│   │   └── database.ts
│   ├── routes/
│   │   ├── index.ts
│   │   └── products.routes.ts
│   ├── app.ts
│   └── index.ts
├── .env
├── .gitignore
├── compose.yaml
├── package.json
└── tsconfig.json

Il file database.ts contiene la logica di connessione a MongoDB. app.ts configura Express e registra le rotte, mentre index.ts avvia il server. Il nuovo file products.routes.ts conterrà gli endpoint CRUD.

Questa separazione non è obbligatoria per un progetto piccolo. Potremmo inserire tutte le rotte direttamente in index.ts, ma dopo pochi endpoint quel file diventerebbe un miscuglio di configurazione, query e gestione delle richieste. Preferisco mantenere ogni componente responsabile di un compito preciso, così il progetto rimane leggibile anche quando cresce.

Configurare la connessione al database

Nel Tutorial 6 abbiamo predisposto la funzione connectToDatabase(). Per il seguito del tutorial assumiamo che sia esportata da src/config/database.ts e che restituisca una Promise del driver ufficiale MongoDB.

Se non hai ancora completato quel file, questa è un'implementazione utilizzabile:

import { MongoClient, type Db } from 'mongodb';

let client: MongoClient | undefined;
let database: Db | undefined;

export async function connectToDatabase(): Promise<Db> {
  if (database) {
    return database;
  }

  const uri = process.env.MONGODB_URI;
  const dbName = process.env.MONGODB_DB;

  if (!uri || !dbName) {
    throw new Error(
      'MONGODB_URI e MONGODB_DB devono essere configurate',
    );
  }

  const newClient = new MongoClient(uri);

  await newClient.connect();

  client = newClient;
  database = newClient.db(dbName);

  console.log(`Connesso al database ${dbName}`);

  return database;
}

Importiamo MongoClient, che rappresenta il client utilizzato per comunicare con il server MongoDB, e il tipo Db, che descrive il database selezionato.

Le due variabili esterne alla funzione conservano il client e il database dopo la prima connessione. Quando la funzione viene richiamata nuovamente, restituisce il database già inizializzato invece di aprire una nuova connessione ogni volta.

Notiamo anche che le variabili d'ambiente vengono lette all'interno della funzione. Questo evita di dipendere dal momento in cui il modulo viene importato: quando chiamiamo la funzione, .env deve essere già stato caricato.

Creare il primo endpoint: inserire un prodotto

Cominciamo da src/routes/products.routes.ts. Importiamo Router da Express, ObjectId dal driver MongoDB e la funzione che ci permette di ottenere il database.

import { Router } from 'express';
import { ObjectId } from 'mongodb';
import { connectToDatabase } from '../config/database.js';

const router = Router();

type Product = {
  name: string;
  price: number;
  description?: string;
  createdAt: Date;
  updatedAt: Date;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object'
    && value !== null
    && !Array.isArray(value);
}

Il tipo Product descrive la struttura dei dati che vogliamo salvare. Il nome e il prezzo sono obbligatori, mentre la descrizione è facoltativa. Le date di creazione e aggiornamento vengono generate dal backend.

Questa definizione è un tipo TypeScript, non uno schema imposto automaticamente da MongoDB. Serve al compilatore per controllare il nostro codice, ma non sostituisce la validazione dei dati ricevuti via HTTP.

La funzione isRecord() controlla che un valore sia un oggetto utilizzabile come insieme di proprietà. Il suo type predicate, espresso con value is Record<string, unknown>, permette a TypeScript di restringere il tipo dopo il controllo. È utile perché non dobbiamo fidarci ciecamente del contenuto di una richiesta ricevuta da un client.

Aggiungiamo ora l'endpoint POST:

router.post('/', async (req, res) => {
  const body: unknown = req.body;

  if (!isRecord(body)) {
    res.status(400).json({
      error: 'Il corpo della richiesta deve essere un oggetto',
    });
    return;
  }

  if (
    typeof body.name !== 'string'
    || body.name.trim().length === 0
  ) {
    res.status(400).json({
      error: 'Il nome del prodotto è obbligatorio',
    });
    return;
  }

  if (
    typeof body.price !== 'number'
    || !Number.isFinite(body.price)
    || body.price < 0
  ) {
    res.status(400).json({
      error: 'Il prezzo deve essere un numero maggiore o uguale a zero',
    });
    return;
  }

  if (
    body.description !== undefined
    && typeof body.description !== 'string'
  ) {
    res.status(400).json({
      error: 'La descrizione deve essere una stringa',
    });
    return;
  }

  const product: Product = {
    name: body.name.trim(),
    price: body.price,
    ...(body.description !== undefined
      ? { description: body.description }
      : {}),
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  const db = await connectToDatabase();
  const result = await db.collection<Product>('products')
    .insertOne(product);

  res.status(201).json({
    id: result.insertedId.toHexString(),
    ...product,
  });
});

Il controllo dei dati avviene prima dell'accesso al database. Se il client invia un nome vuoto o un prezzo negativo, restituiamo HTTP 400, cioè una richiesta non valida, senza effettuare l'inserimento.

La chiamata db.collection('products') seleziona la collection products. Il parametro generico permette a TypeScript di controllare la struttura dei documenti che utilizziamo nel codice.

Il metodo insertOne() inserisce un documento e restituisce un risultato contenente, tra le altre informazioni, l'identificativo generato da MongoDB. Con insertedId.toHexString() convertiamo l'ObjectId in una stringa esadecimale, comoda da restituire al client.

Il codice risponde con HTTP 201, che indica che una nuova risorsa è stata creata. La validazione che abbiamo implementato è volutamente esplicita: per un'applicazione più grande la sposteremo eventualmente in una funzione o in uno strumento dedicato, evitando di ripeterla in ogni endpoint.

Leggere l'elenco dei prodotti

Per leggere tutti i prodotti utilizziamo GET. Aggiungiamo questo codice nello stesso file, dopo l'endpoint POST.

router.get('/', async (_req, res) => {
  const db = await connectToDatabase();

  const products = await db.collection<Product>('products')
    .find({})
    .sort({ createdAt: -1 })
    .toArray();

  res.json(products);
});

Il metodo find({}) esegue una ricerca senza filtri, quindi restituisce tutti i documenti della collection. Con sort({ createdAt: -1 }) ordiniamo i risultati dal più recente al più vecchio. Infine, toArray() raccoglie i risultati del cursore in un array che possiamo restituire in formato JSON.

Il parametro _req è il primo argomento della funzione di gestione della rotta, ma in questo caso non ci serve. L'underscore segnala semplicemente che non lo utilizziamo.

Per recuperare un singolo prodotto utilizziamo invece un parametro nell'URL:

router.get('/:id', async (req, res) => {
  const id = req.params.id;

  if (typeof id !== 'string' || !ObjectId.isValid(id)) {
    res.status(400).json({
      error: 'ID prodotto non valido',
    });
    return;
  }

  const db = await connectToDatabase();

  const product = await db.collection<Product>('products')
    .findOne({ _id: new ObjectId(id) });

  if (!product) {
    res.status(404).json({
      error: 'Prodotto non trovato',
    });
    return;
  }

  res.json(product);
});

MongoDB assegna normalmente ai documenti un campo _id, il cui valore è spesso un ObjectId. Anche se restituiamo l'identificativo al client come stringa, dobbiamo convertirlo nuovamente in ObjectId per utilizzarlo nella query.

ObjectId.isValid() permette di verificare che il valore abbia una rappresentazione accettabile. Se il parametro non è valido restituiamo HTTP 400; se invece è formalmente valido ma non corrisponde a nessun documento, rispondiamo con HTTP 404, risorsa non trovata.

È una distinzione importante: un identificativo malformato e un prodotto inesistente sono due situazioni differenti e meritano risposte differenti.

Modificare un prodotto con PATCH

Per aggiornare un prodotto utilizzeremo PATCH. A differenza di una sostituzione completa, PATCH permette di inviare soltanto i campi che desideriamo modificare.

router.patch('/:id', async (req, res) => {
  const id = req.params.id;

  if (typeof id !== 'string' || !ObjectId.isValid(id)) {
    res.status(400).json({
      error: 'ID prodotto non valido',
    });
    return;
  }

  const body: unknown = req.body;

  if (!isRecord(body)) {
    res.status(400).json({
      error: 'Il corpo della richiesta deve essere un oggetto',
    });
    return;
  }

  const updates: {
    name?: string;
    price?: number;
    description?: string;
    updatedAt: Date;
  } = {
    updatedAt: new Date(),
  };

  if ('name' in body) {
    if (
      typeof body.name !== 'string'
      || body.name.trim().length === 0
    ) {
      res.status(400).json({
        error: 'Il nome deve essere una stringa non vuota',
      });
      return;
    }

    updates.name = body.name.trim();
  }

  if ('price' in body) {
    if (
      typeof body.price !== 'number'
      || !Number.isFinite(body.price)
      || body.price < 0
    ) {
      res.status(400).json({
        error: 'Il prezzo deve essere un numero maggiore o uguale a zero',
      });
      return;
    }

    updates.price = body.price;
  }

  if ('description' in body) {
    if (typeof body.description !== 'string') {
      res.status(400).json({
        error: 'La descrizione deve essere una stringa',
      });
      return;
    }

    updates.description = body.description;
  }

  if (
    !('name' in body)
    && !('price' in body)
    && !('description' in body)
  ) {
    res.status(400).json({
      error: 'Specifica almeno un campo da modificare',
    });
    return;
  }

  const db = await connectToDatabase();

  const result = await db.collection<Product>('products')
    .updateOne(
      { _id: new ObjectId(id) },
      { $set: updates },
    );

  if (result.matchedCount === 0) {
    res.status(404).json({
      error: 'Prodotto non trovato',
    });
    return;
  }

  const updatedProduct = await db.collection<Product>('products')
    .findOne({ _id: new ObjectId(id) });

  res.json(updatedProduct);
});

Qui entra in gioco $set, un operatore di aggiornamento MongoDB. Modifica soltanto i campi specificati, lasciando invariati quelli non presenti nell'oggetto updates.

La proprietà updatedAt viene aggiornata automaticamente dal backend. Il controllo finale evita di accettare una richiesta che non contiene nessun campo modificabile.

matchedCount indica quanti documenti hanno soddisfatto il filtro. Se vale zero, il prodotto non esiste e restituiamo HTTP 404. Se invece il documento è stato trovato, lo recuperiamo nuovamente per restituire al client lo stato aggiornato.

Questa implementazione mostra bene il funzionamento del driver, ma non è ancora una soluzione definitiva per ogni scenario: in un'applicazione reale valuteremo anche aggiornamenti atomici con restituzione del documento aggiornato, gestione dei campi sconosciuti e regole di autorizzazione.

Eliminare un prodotto

L'ultima operazione è DELETE. Il codice è più breve perché non dobbiamo costruire un nuovo documento o validare campi modificabili.

router.delete('/:id', async (req, res) => {
  const id = req.params.id;

  if (typeof id !== 'string' || !ObjectId.isValid(id)) {
    res.status(400).json({
      error: 'ID prodotto non valido',
    });
    return;
  }

  const db = await connectToDatabase();

  const result = await db.collection<Product>('products')
    .deleteOne({ _id: new ObjectId(id) });

  if (result.deletedCount === 0) {
    res.status(404).json({
      error: 'Prodotto non trovato',
    });
    return;
  }

  res.status(204).send();
});

deleteOne() elimina al massimo un documento che corrisponde al filtro. Se nessun documento viene eliminato, rispondiamo con HTTP 404. Se l'operazione riesce, restituiamo HTTP 204, che indica un risultato positivo senza contenuto nel corpo della risposta.

A questo punto, in products.routes.ts, abbiamo implementato tutte e cinque le rotte: inserimento, lettura dell'elenco, lettura del singolo elemento, modifica ed eliminazione.

Registrare le rotte in Express

Le rotte esistono, ma Express deve sapere dove trovarle. In src/routes/products.routes.ts, dopo tutti gli endpoint, esportiamo il router:

export default router;

Poi aggiorniamo src/app.ts:

import express, {
  type NextFunction,
  type Request,
  type Response,
} from 'express';

import router from './routes/index.js';
import productsRouter from './routes/products.routes.js';

const app = express();

app.use(express.json());

app.use('/', router);
app.use('/api/products', productsRouter);

app.use((
  err: unknown,
  _req: Request,
  res: Response,
  _next: NextFunction,
) => {
  console.error(err);

  res.status(500).json({
    error: 'Errore interno del server',
  });
});

export default app;

Il middleware express.json() permette a Express di interpretare i corpi delle richieste JSON. Senza di esso, req.body potrebbe non contenere i dati che il client ha inviato.

La riga app.use('/api/products', productsRouter) associa il router al prefisso /api/products. Di conseguenza, la rotta POST definita come '/' diventa POST /api/products, mentre la rotta GET definita come '/:id' diventa GET /api/products/:id.

Il middleware finale gestisce gli errori che vengono inoltrati a Express. Con Express 5, gli errori e le Promise rifiutate nei gestori asincroni vengono inoltrati automaticamente al middleware degli errori. In questo modo un problema imprevisto nel database non obbliga ogni singola rotta a ripetere lo stesso blocco try/catch.

In produzione dovremo evitare di esporre dettagli interni degli errori ai client. Qui registriamo l'errore sul server e restituiamo un messaggio generico.

Avviare il server dopo la connessione

In src/index.ts dobbiamo caricare .env, collegarci al database e soltanto dopo avviare Express.

import 'dotenv/config';

import app from './app.js';
import { connectToDatabase } from './config/database.js';

const port = Number(process.env.PORT) || 3000;

async function startServer(): Promise<void> {
  await connectToDatabase();

  app.listen(port, () => {
    console.log(`Server in ascolto sulla porta ${port}`);
  });
}

startServer().catch((error: unknown) => {
  console.error('Impossibile avviare il server:', error);
  process.exitCode = 1;
});

Questa sequenza è intenzionale. Se MongoDB non è raggiungibile, l'applicazione non dovrebbe annunciare di essere pronta ad accettare richieste che non può gestire correttamente.

La funzione startServer() attende la connessione grazie ad await. Se la connessione fallisce, la Promise viene rifiutata e il catch registra l'errore e imposta un codice di uscita non riuscita.

Testare le API con curl

Ora possiamo avviare il progetto in modalità sviluppo:

npx tsx src/index.ts

Apriamo un secondo terminale e creiamo un prodotto:

curl -i -X POST http://localhost:3000/api/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"Tastiera meccanica","price":79.90,"description":"Tastiera USB"}'

La risposta dovrebbe contenere HTTP 201 e un campo id. Conserviamo quell'identificativo: ci servirà per le richieste successive.

Per leggere tutti i prodotti:

curl -i http://localhost:3000/api/products

Per recuperare un singolo prodotto, sostituiamo ID_DEL_PRODOTTO con l'identificativo restituito dal POST:

curl -i http://localhost:3000/api/products/ID_DEL_PRODOTTO

Per modificare il prezzo:

curl -i -X PATCH http://localhost:3000/api/products/ID_DEL_PRODOTTO \
  -H 'Content-Type: application/json' \
  -d '{"price":69.90}'

Infine, per eliminare il prodotto:

curl -i -X DELETE http://localhost:3000/api/products/ID_DEL_PRODOTTO

Se tutto funziona, la risposta finale sarà HTTP 204. Se ripetiamo la GET sul prodotto eliminato, otterremo HTTP 404.

Possiamo verificare anche la validazione inviando un prezzo negativo o un nome vuoto. Il backend dovrebbe rispondere con HTTP 400 senza inserire il documento. Questi test sono importanti quanto quelli positivi: un'API non deve funzionare soltanto quando tutti inviano dati perfetti, perché i client, come sappiamo, hanno una fantasia notevole nel produrre input improbabili.

Conclusioni

Abbiamo implementato un CRUD completo con Express, TypeScript e il driver ufficiale MongoDB. Il backend ora riceve richieste HTTP, valida i dati essenziali, esegue query sulla collection products e restituisce risposte con codici di stato appropriati.

Abbiamo anche introdotto alcuni concetti che ritroveremo in tutti i backend: i tipi TypeScript, la validazione dei dati in ingresso, gli identificativi ObjectId, gli operatori di aggiornamento e la gestione centralizzata degli errori.

Questa è una buona base didattica, ma non è ancora un'API pronta per la produzione. Prima di esporla pubblicamente dovremo aggiungere autenticazione e autorizzazione, limiti e paginazione sulle liste, validazione più strutturata, protezione contro richieste eccessive e test automatici. Dovremo inoltre valutare gli indici MongoDB quando le query diventeranno più complesse.

Nel prossimo tutorial potremo separare ulteriormente le responsabilità del backend, introducendo un livello di servizio tra le rotte e il database. In questo modo gli endpoint HTTP non conterranno direttamente tutta la logica applicativa e il progetto sarà più facile da testare ed estendere.