6. back-end, NestJS en MongoDB
In dit hoofdstuk bekijken we de basisconcepten van back-end ontwikkeling met NestJS en MongoDB. We zullen zien hoe we een eenvoudige REST API kunnen opzetten met NestJS, hoe we data kunnen opslaan in een MongoDB database, en hoe we deze API kunnen integreren met onze Angular front-end.
Dit hoofdstuk is opbouwend geschreven. Dit wilt zeggen dat je op sommige plaatsen niet direct de werkende code zal zien, of best practices. Dit is bewust zo gedaan om de concepten stap voor stap uit te leggen. Aan het einde van dit hoofdstuk zal je een volledig werkende back-end hebben met NestJS en MongoDB. Het is aan te raden om het hoofdstuk volledig in volgorde door te nemen en stapsgewijs mee te bouwen.
1. Front-end en back-end
We hebben tot nu toe gezien hoe we een webapplicatie kunnen maken die elementen toont aan de gebruiker in de browser. Dit is wat we noemen de front-end van een webapplicatie. We hebben ook gezien dat we data kunnen opslaan in de browser om te zorgen dat deze bewaard blijft als de gebruiker de pagina verlaat. Echter, deze data is alleen beschikbaar op dat specifieke apparaat en in die specifieke browser. Wat als de data beschikbaar moet zijn op meerdere apparaten of gedeeld moet worden met andere gebruikers? Dan gaan we dit sturen naar een server waar de data centraal opgeslagen en verwerkt kan worden. Dit noemen we de back-end van een webapplicatie.
In een typische webapplicatie communiceert de front-end met de back-end via API's (Application Programming Interfaces). De front-end stuurt verzoeken naar de back-end om data op te halen of op te slaan, en de back-end verwerkt deze verzoeken en stuurt de benodigde data terug naar de front-end. We hebben dit al kort gezien bij het gebruik van asynchrone functies en fetch calls.
In Angular maken we gebruik van de combinatie van Angular voor de front-end en Express of NestJS voor de back-end. Deze back-end draait op Node.js, een javascript runtime die het mogelijk maakt om JavaScript buiten de browser uit te voeren, bijvoorbeeld op een server.
2. Tech-stacks
Wanneer we een front-end en back-end combineren, spreken we van een tech-stack. Dit is een verzameling van technologieën die samen worden gebruikt om een volledige webapplicatie te bouwen. Er zijn verschillende populaire tech-stacks, elk met hun eigen voor- en nadelen. Hier zijn enkele voorbeelden:
- MEAN Stack: MongoDB, Express.js, Angular, Node.js
- MERN Stack: MongoDB, Express.js, React, Node.js
- LAMP Stack: Linux, Apache, MySQL, PHP
Je moet niet per se alle stacks te kennen en er is geen "beste" stack. Het belangrijkste is dat je begrijpt hoe de verschillende onderdelen samenwerken en dat je in staat bent om de juiste tools te kiezen voor jouw specifieke project.
In deze cursus richten we ons op de MEAN Stack, waarbij we MongoDB gebruiken als database, Express.js of NestJS als back-end framework, Angular als front-end framework, en Node.js als runtime omgeving. De MEAN Stack staat ook bekend om het feit dat het volledig gebaseerd is op JavaScript. We kunnen dus dezelfde programmeertaal gebruiken voor zowel de front-end als de back-end, wat de ontwikkeling eenvoudiger maakt.
Bij de MEAN-stack wordt data in JSON-formaat uitgewisseld tussen de front-end en back-end. JSON (JavaScript Object Notation) is een lichtgewicht dataformaat dat gemakkelijk te lezen en te schrijven is voor zowel mensen als machines. Het ziet er ongeveer zo uit:
{
"naam": "Jan",
"leeftijd": 30,
"beroep": "Ontwikkelaar"
}
3. Node.js
JavaScript werd oorspronkelijk gemaakt om in de browser te draaien. In de browser zit JavaScript in een soort “veilige bubbel” (sandbox). Daardoor is het beperkt in wat het mag doen: het kan bijvoorbeeld niet zomaar aan je bestanden, je netwerk of je volledige systeem. Dat is bewust zo ontworpen, want JavaScript wordt uitgevoerd wanneer je een website bezoekt, en het zou een groot beveiligingsrisico zijn als websites volledige controle over je computer zouden krijgen.
Node.js maakt het mogelijk om JavaScript buiten de browser uit te voeren, bijvoorbeeld op een server of op je eigen computer. Je kunt het zien als een programma dat JavaScript kan lezen en uitvoeren, net zoals de browser dat doet, maar dan zonder de beperkingen van die sandbox.
Omdat Node.js bovenop de V8-engine van Google Chrome is gebouwd, is het snel, en krijg je er extra mogelijkheden bij: werken met bestanden, netwerkverkeer, processen en nog veel meer. Daardoor kun je met JavaScript niet alleen websites bouwen, maar ook backends, scripts, tools en complete applicaties draaien, zonder dat daar een browser voor nodig is.
npm
npm staat voor Node Package Manager. Dit beheert de libraries en tools die je gebruikt in je Node.js-projecten. We hebben npm al eerder gebruikt bij het installeren van Angular en andere tools.
npm install -g @angular/cli
Ook tools zoals de Typescript-compiler worden via npm geïnstalleerd en beheerd:
npm install -g typescript
Node in Angular
Angular zelf draait ook op Node.js tijdens de ontwikkeling. Dit is een zogenaamde "development server" die je applicatie bouwt, aanbiedt (served) en automatisch herlaadt wanneer je wijzigingen aanbrengt. Wanneer je dan je applicatie bouwt voor productie (ng build --prod), wordt de code gecompileerd, zodat het in de moderne browser kan draaien zonder Node.js.
4. Het service-patroon in Angular
We hebben eerder al gezien dat we in Angular gebruik kunnen maken van services om logica en data te scheiden van de componenten. Om data te kunnen opslaan en ophalen hebben we het concept van mini-stores besproken. Deze mini-stores kunnen we uitbreiden om te communiceren met een back-end server. We kunnen een service maken die verantwoordelijk is voor het ophalen en opslaan van data via API calls naar onze back-end. Dit doen we met behulp van Angular's HttpClient module.
Hier is een voorbeeld van een eenvoudige service die taken ophaalt en opslaat via een back-end API:
// src/app/services/task-service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Task } from '../models/task.model';
@Injectable({
providedIn: 'root'
})
export class TaskService {
private apiUrl = 'http://localhost:3000/api/tasks';
private http = inject(HttpClient)
getTasks(): Observable<Task[]> {
return this.http.get<Task[]>(this.apiUrl);
}
getTask(): Observable<Task> {
return this.http.get<Task>(`${this.apiUrl}/${id}`);
}
}
We maken gebruik van het model Task in dit voorbeeld. Dit model. De invulling hiervan bepaal je zelf, afhankelijk van de data waarmee je wilt werken. Bijvoorbeeld:
// src/app/models/task.model.ts
export interface Task {
id: number;
todo: string;
completed: boolean;
}
Let op: Dit voorbeeld gaat nog niet werken, omdat we nog geen back-end server hebben opgezet die deze API aanbiedt. We zullen later in dit hoofdstuk zien hoe we een eenvoudige back-end kunnen opzetten met NestJS en MongoDB.
We kunnen deze service vervolgens injecteren in onze componenten en gebruiken om taken op te halen en weer te geven:
// src/app/components/task-list/task-list.ts
import { inject, Component } from '@angular/core';
import { TaskService } from '../../services/task-service';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-task-list',
imports: [],
templateUrl: './task-list.html',
styleUrl: './task-list.css',
})
export class TaskList {
private taskService = inject(TaskService);
tasks = toSignal(this.taskService.getTasks(), { initialValue: [] });
}
In dit voorbeeld gebruiken we de TaskService om taken op te halen van de back-end en deze weer te geven in de TaskList component. We maken gebruik van de toSignal functie om de Observable die wordt geretourneerd door getTasks() om te zetten naar een signaal dat we in onze template kunnen gebruiken.
5. Wat is een API?
API staat voor Application Programming Interface. Het is een set van regels en protocollen die bepalen hoe verschillende softwarecomponenten met elkaar kunnen communiceren. In de context van webontwikkeling verwijst een API meestal naar een web-API, die het mogelijk maakt voor verschillende applicaties om gegevens en functionaliteit uit te wisselen via het internet. Stel bijvoorbeeld dat je in een restaurant een maltijd bestelt. De ober fungeert als een API tussen jou en de keuken. Jij (de client) geeft je bestelling door aan de ober (de API) die deze vervolgens doorgeeft aan de keuken (de server). Wanneer je maaltijd klaar is, brengt de ober deze weer terug naar jou. Je hoeft niet te weten hoe de keuken werkt, of hoe de maaltijd wordt bereid om je bestelling te plaatsen, je vertrouwt erop dat de ober dit voor je regelt.
REST API's
Er bestaan verschillende soorten API's maar de meest gebruikte in webontwikkeling zijn REST API's. REST staat voor Representational State Transfer. Deze term komt je misschien niet direct bekend voor, maar we hebben het al wel eerder gebruikt.
REST API's maken gebruik van standaard HTTP-methoden zoals GET, POST, PUT, DELETE om acties uit te voeren op resources die worden voorgesteld in een webapplicatie. Elke resource wordt geïdentificeerd door een unieke URL. Bijvoorbeeld, in een taakbeheerapplicatie zou je de volgende endpoints kunnen hebben:
GET /api/tasks: Haalt een lijst op van alle taken.GET /api/tasks/{id}: Haalt een specifieke taak op op basis van de ID.POST /api/tasks: Maakt een nieuwe taak aan.PUT /api/tasks/{id}: Werkt een bestaande taak bij op basis van de ID.DELETE /api/tasks/{id}: Verwijdert een taak op basis van de ID.
Restful principes
REST API's volgen een aantal principes om consistentie en eenvoud te waarborgen:
- Elk request van de client naar de server moet alle informatie bevatten die nodig is om het request te begrijpen en te verwerken. Het mag dus geen context van eerdere requests vereisen.
- De API beheert resources. Elke resource-group (zoals
tasks,users, etc.) wordt vertegenwoordigd door een unieke URL. - Elke actie op een resource wordt uitgevoerd met behulp van standaard HTTP-methoden. Deze methoden mogen niet worden gebruikt voor een andere actie dan waarvoor ze bedoeld zijn. Bijvoorbeeld, een GET request mag geen data wijzigen op de server, en een POST request wordt gebruikt om nieuwe resources aan te maken.
Wanneer een REST API strikt deze principes volgt, wordt het een "RESTful" API genoemd. Soms is het moeilijk om alle principes volledig te volgen, maar het streven naar RESTful ontwerp helpt bij het creëren van duidelijke en onderhoudbare API's.
Testen van API's
Een GET request kan je eenvoudig testen in je browser door de URL in te voeren. Je browser zal dan standaard een GET request uitvoeren en de response tonen. Voor andere HTTP-methoden zoals POST, PUT en DELETE kan je gebruik maken van tools zoals (Postman)[https://www.postman.com/] of (Insomnia)[https://insomnia.rest/]. Deze tools bieden een gebruiksvriendelijke interface om verschillende soorten HTTP-requests te maken, headers en body data toe te voegen, en de responses van de server te bekijken. Ze zijn onmisbaar voor het testen en debuggen van API's tijdens de ontwikkeling.
Soms heb je geen optie om een grafische tool te gebruiken, bijvoorbeeld wanneer je werkt op een server zonder grafische interface. In dat geval kan je gebruik maken van command-line tools zoals curl. Bijvoorbeeld, om een POST request te maken met curl, kan je de volgende command gebruiken:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"todo": "Nieuwe taak", "completed": false}'
Dit stuurt een POST request naar de opgegeven URL met een JSON body die een nieuwe taak aanmaakt.
6. Express.js
Express.js is een minimalistisch en flexibel webframework voor Node.js, dat zich richt op server-side webontwikkeling. Het maakt het eenvoudig om snel HTTP-servers en API's op te zetten. Express biedt handige functionaliteiten voor routing, middleware en error handling. Zo kun je bijvoorbeeld gemakkelijk verschillende routes definiëren, requests en responses verwerken met middleware, en fouten op een gestructureerde manier afhandelen. Omdat Express relatief lichtgewicht is en niet zo gestructureerd als sommige andere frameworks, geeft het ontwikkelaars veel vrijheid om hun eigen architectuur te kiezen. Dit betekent echter ook dat je meer zelf moet opzetten en organiseren om een overzichtelijke en onderhoudbare applicatie te bouwen.
// server.ts
import express from 'express';
const app = express();
const port = 3000;
app.use(express.json());
let tasks = []
app.get('/api/tasks', (req, res) => {
res.json(tasks);
});
app.post('/api/tasks', (req, res) => {
const newTask = req.body;
} tasks.push(newTask);
res.status(201).json(newTask);
});
app.listen(port, () => {
console.log ("server running on http://localhost:" + port);)
In dit voorbeeld maken we een eenvoudige Express-server die luistert op poort 3000. We hebben twee routes gedefinieerd: een GET route om alle taken op te halen en een POST route om een nieuwe taak toe te voegen. We gebruiken express.json() middleware om JSON request bodies te kunnen verwerken.
Hier komt de CRUD-flow terug: de GET route haalt alle taken op (Read) en de POST route voegt een nieuwe taak toe (Create). We zouden ook PUT en DELETE routes kunnen toevoegen om taken bij te werken en te verwijderen.
Dataflow
Als we de front-end en back-end combineren, ontstaat er een dataflow tussen de verschillende componenten van de applicatie. Hieronder zie je een diagram dat deze flow illustreert:
- De Angular component vraagt data op via de service.
- De service stuurt een HTTP-request naar de Express API.
- De API verwerkt de request en voert de benodigde business logica uit.
- De backend communiceert met de database om data op te halen of bij te werken.
- De database retourneert het resultaat aan de backend.
- De backend stuurt de data terug naar de API in JSON-formaat.
- De API stuurt het JSON-antwoord terug naar de Angular service.
- De service werkt de Signals bij met de ontvangen data.
- De Angular component wordt automatisch bijgewerkt dankzij de reactieve aard van Signals, waardoor de UI de nieuwste data toont.
In kleine applicaties kan het zijn dat er geen database is en dat de backend alleen maar eenvoudige logica uitvoert. In dat geval kan de backend direct data terugsturen zonder een database te raadplegen.
7. Validatie en error handling
Zoals eerder besproken draait onze back-end code in Node.js, dus buiten de browser. Dit betekent dat als een gebruiker erin zou slagen om kwaadaardige code naar onze back-end te sturen, deze code op de server kan worden uitgevoerd. Dat kan leiden tot ernstige beveiligingsproblemen, zoals datalekken of het overnemen van de server. Daarom is het cruciaal om altijd de binnenkomende data te valideren en te sanitiseren:
- Validatie controleert of de ontvangen data voldoet aan de verwachte structuur en regels. Bijvoorbeeld: als we een taak verwachten met een
todoveld van het type string en eencompletedveld van het type boolean, moeten we controleren of deze velden aanwezig zijn en van het juiste type zijn. - sanitisatie is het proces van het opschonen van de data om ervoor te zorgen dat er geen kwaadaardige code in zit. bijvoorbeeld, als we een string ontvangen die HTML bevat, kunnen we deze sanitiseren door alle HTML-tags te verwijderen om te voorkomen dat er scripts worden uitgevoerd.
Indien de data niet geldig is, of om een andere reden het request niet kan worden verwerkt, is het belangrijk om een passende foutmelding terug te sturen. Door dit te doen kunnen we de front-end informeren over wat er mis is gegaan, zodat deze de gebruiker kan informeren of de nodige acties kan ondernemen.
app.post('/api/tasks', (req, res) => {
const newTask = req.body;
// Validatie
if (typeof newTask.todo !== 'string' || typeof newTask.completed !== 'boolean') {
return res.status(400).json({ error: 'Ongeldige taakgegevens' });
}
tasks.push(newTask);
return res.status(201).json(newTask);
});
In dit voorbeeld valideren we de binnenkomende taakgegevens voordat we deze toevoegen aan onze takenlijst. Als de gegevens niet geldig zijn, sturen we een 400 Bad Request statuscode terug met een foutmelding.
In synchrone code zal express automatisch fouten afhandelen die worden gegooid in route handlers of middleware. Echter, als er iets misgaat in asynchrone functies (zoals bij het gebruik van Promises of async/await), zal express deze fout niet automatisch opvangen. Als er een fout optreedt die niet wordt afgehandeld, kan dit leiden tot geen response naar de client.
Om dit te voorkomen, is het belangrijk om fouten in asynchrone code expliciet af te handelen, bijvoorbeeld door gebruik te maken van try/catch blokken of door de fout door te geven aan de volgende middleware met next(err).
app.get('/api/tasks/:id', async (req, res, next) => {
try {
const task = await getTaskById(req.params.id);
if (!task) {
return res.status(404).json({ error: 'Taak niet gevonden' });
}
res.json(task);
} catch (err) {
next(err); // Fout doorgeven aan de foutafhandelingsmiddleware
}
});
Deze error handling en validatie zorgt wel dat onze back-end veiliger wordt, maar het toont niet automatisch iets aan de gebruiker. We moeten er in de front-end ook voor zorgen dat we deze foutmeldingen opvangen en tonen aan de gebruiker, zodat deze weet wat er mis is gegaan en eventueel actie kan ondernemen.
Kort samengevat: Validatie en error handling gebeuren zowel op front-end als back-end.
- Op de front-end maken we gebruik van formulieren met validatie, knoppen die geactiveerd of gedeactiveerd worden op basis van de geldigheid van de data, en het tonen van foutmeldingen aan de gebruiker. De prmaire use-case hiervoor is de user experience verbeteren.
- Op de back-end valideren we alle binnenkomende data en sanitiseren we deze om beveiligingsrisico's te minimaliseren. We sturen passende foutmeldingen en HTTP-statuscodes terug naar de front-end wanneer iets misgaat. De primaire use-case hiervoor is de veiligheid en integriteit van de applicatie waarborgen.
8. NestJS
NestJS is een back-end framework voor Node.js dat gebouwd is met TypeScript en sterk geïnspireerd is op Angular. Het wordt meestal gecombineerd met Express.js. Het biedt een gestructureerde, schaalbare en onderhoudbare manier om server-side applicaties te bouwen. Net zoals Angular werkt NestJS met modules, controllers, services en dependency injection. Het is opinionated, wat betekent dat het een vaste manier van werken voorschrijft. Dat past niet altijd bij iedereen, maar het kan wel zeer productief zijn.
NestJS sluit naadloos aan bij de concepten die we kennen uit Angular. Het heeft een duidelijke structuur en, net zoals met Express.js, kunnen we er REST-API’s mee bouwen. Daarnaast ondersteunt het verschillende integraties, zoals databases (MongoDB, PostgreSQL, …), authenticatie (JWT, OAuth, …) en WebSockets.
Installatie
Om NestJS te gebruiken moeten we eerst de NestJS CLI installeren via npm:
npm install -g @nestjs/cli
Vervolgens kunnen we een nieuw NestJS project aanmaken met de CLI:
nest new my-nestjs-app
Dit zal eerst vragen welk package manager we willen gebruiken (npm of yarn). Kies hier voor npm als we de standaard package manager willen gebruiken. Hierna zal je te zien krijgen dat de installatie wordt uitgevoerd
Installation in progress... ☕
Soms kan dit een paar minuten duren, afhankelijk van je internetverbinding en de snelheid van je computer.
De installatie is klaar wanneer je het volgende ziet:
🚀 Successfully created project my-nestjs-app
👉 Get started with the following commands:
$ cd my-nestjs-app
$ npm run start
Thanks for installing Nest 🙏
Please consider donating to our open collective
to help us maintain this package.
🍷 Donate: https://opencollective.com/nest
Dit zal een nieuwe map my-nestjs-app aanmaken met een basisstructuur voor een NestJS applicatie. We kunnen dan de benodigde dependencies installeren en de server starten:
cd my-nestjs-app
npm install
npm run start:dev
De server zal nu draaien op http://localhost:3000. We kunnen dit testen door deze URL in onze browser te openen. We zouden een welkomstbericht van NestJS moeten zien.
We moeten dit niet op poort 3000 draaien, je kunt dit aanpassen in het main.ts bestand:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3001);
}
bootstrap();
Hiermee zal de server luisteren op poort 3001. Je kan dit ook aanpassen door een environment variable in te stellen, maar voor nu is dit voldoende.
Om de server te stoppen, kunnen we in de terminal waar de server draait Ctrl + C indrukken.
Basisstructuur
Een nieuw NestJS project heeft een basisstructuur die lijkt op de volgende:
src/
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
└── products
├── products.controller.spec.ts
├── products.controller.ts
├── products.module.ts
├── products.service.spec.ts
└── products.service.ts
main.ts: Dit is het entry point van de applicatie. Hier wordt de NestJS applicatie gecreëerd en gestart.app.module.ts: Dit is de root module van de applicatie. Modules zijn een manier om gerelateerde functionaliteit te groeperen.app.controller.ts: Controllers zijn verantwoordelijk voor het afhandelen van inkomende HTTP-requests en het terugsturen van responses.app.service.ts: Services bevatten de business logica van de applicatie en worden vaak gebruikt door controllers om data te verwerken.products/: Dit is een voorbeeld van een feature module die gerelateerde functionaliteit voor producten bevat, inclusief een controller en een service.
Modules
NestJS maakt gebruik van modules om de applicatie op te delen in logische domeinen. Elke module kan controllers, services en andere providers bevatten die gerelateerd zijn aan een specifiek onderdeel van de applicatie.
Elke app start met een root module, meestal AppModule genoemd. We kunnen extra modules aanmaken om verschillende functionaliteiten te organiseren. Bijvoorbeeld, we kunnen een TasksModule maken voor het beheren van taken:
nest generate module tasks
Dit zal een nieuwe map tasks aanmaken met een tasks.module.ts bestand erin en de module registreren in de app.module.ts:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [TasksModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
De TasksModule ziet er als volgt uit:
import { Module } from '@nestjs/common';
@Module({})
export class TasksModule {}
Op dit moment is de module nog leeg. We kunnen nu controllers en services toevoegen aan deze module om de functionaliteit voor taken te implementeren.
In AppModule importeren we de TasksModule, zodat deze beschikbaar is in de applicatie. Best practice is om in AppModule alleen modules te importeren, en geen controllers of services direct te registreren.
Controllers
Controllers in NestJS zijn verantwoordelijk voor het afhandelen van inkomende HTTP-requests en het terugsturen van responses. We kunnen een controller aanmaken voor onze TasksModule om taken te beheren:
nest generate controller tasks
Dit zal een nieuw bestand tasks.controller.ts aanmaken in de tasks map:
// src/tasks/tasks.controller.ts
import { Controller } from '@nestjs/common';
@Controller('tasks')
export class TasksController {}
De @Controller('tasks') decorator geeft aan dat deze controller verantwoordelijk is voor routes die beginnen met /tasks. Daarnaast wordt ook automatisch de controller geregistreerd in de TasksModule:
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
@Module({
controllers: [TasksController],
})
export class TasksModule {}
We kunnen nu methoden toevoegen aan de controller om verschillende HTTP-requests af te handelen, zoals GET en POST:
// src/tasks/tasks.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
@Get()
getAllTasks() {
return {
tasks: [{ id: 1, title: 'Task One', description: 'This is task one' }],
};
}
}
In dit voorbeeld hebben we een getAllTasks methode toegevoegd die een lijst van taken terugstuurt wanneer er een GET-request wordt gedaan naar /tasks.
We maken gebruik van een statische lijst van taken voor dit voorbeeld. We zullen later zien hoe we dit kunnen uitbreiden met een service en een database.
We kunnen nu naar http://localhost:3000/tasks gaan in onze browser om de lijst van taken te zien.
Om de HTTP-methoden te definiëren, gebruiken we decorators zoals @Get(), @Post(), @Put(), en @Delete(). Deze decorators geven aan welke HTTP-methode een bepaalde controller-methode afhandelt.
Decorators
In de controller gebruiken we verschillende decorators om routes en parameters te definiëren:
@Controller('tasks'): Definieert een controller voor de/tasksroute@Get(): Definieert een methode die een GET-request afhandelt@Post(): Definieert een methode die een POST-request afhandelt@Param('id'): Haalt een route-parameter op uit de URL (bv./tasks/:id)@Param(): Haalt alle route-parameters op als een object@Body(): Haalt de body van een POST- of PUT-request op@Query('search'): Haalt een query-parameter op uit de URL (bv./tasks?search=term)
Services
Services in NestJS bevatten de business logica van de applicatie en worden vaak gebruikt door controllers om data te verwerken. We kunnen een service aanmaken voor onze TasksModule om de logica voor taken te beheren:
nest generate service tasks
Dit zal een nieuw bestand tasks.service.ts aanmaken in de tasks map:
import { Injectable } from '@nestjs/common';
@Injectable()
export class TasksService {}
De @Injectable() decorator geeft aan dat deze klasse een provider is die kan worden geïnjecteerd in andere klassen, zoals controllers.
Daarnaast wordt ook automatisch de service geregistreerd in de TasksModule:
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
Om nu de service te gebruiken gaan we eerst een interface definiëren voor onze taak:
// src/tasks/task.interface.ts
export interface Task {
id: number;
title: string;
description: string;
completed: boolean;
}
Hiervoor wordt geen stricte structuur opgelegd in NestJS. Sommigen gebruiken een bestand met .interfaces.ts, anderen maken in elke module een interfacesmap aan. Je kunt zelf bepalen wat voor jou het beste werkt, zolang je maar consistent bent binnen je project.
We kunnen nu methoden toevoegen aan de service om taken te beheren:
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { Task } from './task.interface';
@Injectable()
export class TasksService {
private tasks: Task[] = [
{ id: 1, title: 'Task One', description: 'This is task one' , completed: false},
{ id: 2, title: 'Task Two', description: 'This is task two' , completed: true},
];
getAllTasks(): Task[] {
return this.tasks;
}
getTaskById(id: number): Task | undefined {
return this.tasks.find(task => task.id === id);
}
}
Voorlopig gebruiken we een statische lijst van taken en enkel de methoden om taken op te halen. We zullen dit later uitbreiden met methoden om taken te maken, bij te werken en te verwijderen.
We kunnen nu de service injecteren in de controller en gebruiken om taken op te halen:
// src/tasks/tasks.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { Task } from './task.interface';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
getAllTasks(): Task[] {
return this.tasksService.getAllTasks();
}
@Get(':id')
getTaskById(@Param('id') id: string) {
return this.tasksService.getTaskById(Number(id));
}
}
In dit voorbeeld hebben we de TasksService geïnjecteerd in de TasksController via de constructor. We gebruiken vervolgens de service om alle taken op te halen in de getAllTasks methode en een specifieke taak op te halen in de getTaskById methode.
Als we nu naar http://localhost:3000/tasks gaan in onze browser, zullen we de lijst van taken zien die wordt opgehaald via de service. We kunnen ook een specifieke taak opvragen door naar http://localhost:3000/tasks/1 te gaan, waarbij 1 de ID is van de taak die we willen ophalen.
In de voorbeelden gaan we ervan uit dat je de server op poort 3000 draait. Als je een andere poort hebt ingesteld, zorg er dan voor dat je de juiste poort gebruikt in de URL.
POST-request
We kunnen nu al taken opvragen via GET-requests, maar we willen ook taken kunnen aanmaken. Hiervoor voegen we eerst een methode toe aan de service om een nieuwe taak toe te voegen:
// src/tasks/tasks.service.ts
createTask(title: string, description: string): Task {
const newTask: Task = {
id: this.tasks.length + 1,
title,
description,
completed: false,
};
this.tasks.push(newTask);
return newTask;
}
Merk op hoe we de ID toekennen aan de nieuwe taak. We geven de gebruiker niet de mogelijkheid om zelf een ID te kiezen maar we genereren deze automatisch op basis van het aantal taken dat we al hebben. In een echte applicatie met een database zou de database dit voor ons doen. Ook de waarde voor completed wordt standaard op false gezet bij het aanmaken van een nieuwe taak.
Vervolgens voegen we een POST-route toe aan de controller om nieuwe taken te maken:
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Get()
getAllTasks() {
return this.tasksService.getAllTasks();
}
@Get(':id')
getTaskById(@Param('id') id: string) {
return this.tasksService.getTaskById(Number(id));
}
@Post()
createTask(@Body() body: { title: string; description: string }) {
return this.tasksService.createTask(body.title, body.description);
}
}
Nu kan je een POST-request sturen naar /tasks met een JSON-body die de titel en beschrijving van de nieuwe taak bevat. De controller zal deze data doorgeven aan de service om de taak aan te maken. Dit kunnen we testen met de tools die we eerder hebben besproken, zoals Postman of curl.
Nadat je het Post-request hebt gestuurd, kan je een GET-request doen naar /tasks om te zien dat de nieuwe taak is toegevoegd aan de lijst.
PUT-request
We kunnen nu al taken aanmaken, en opvragen, maar we willen ook taken kunnen bijwerken. Hiervoor voegen we eerst een methode toe aan de service om een taak bij te
// src/tasks/tasks.service.ts
updateTask(id: number, title: string, description: string , completed: boolean): Task | undefined {
const task = this.getTaskById(id);
if (task) {
task.title = title;
task.description = description;
task.completed = false;
}
return task;
}
Vervolgens voegen we een PUT-route toe aan de controller om taken bij te werken:
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Put, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Get()
getAllTasks() {
return this.tasksService.getAllTasks();
}
@Get(':id')
getTaskById(@Param('id') id: string) {
return this.tasksService.getTaskById(Number(id));
}
@Post()
createTask(@Body() body: { title: string; description: string }) {
const newTask = this.tasksService.createTask(body.title, body.description);
return newTask;
}
@Put(':id')
updateTask(
@Param('id') id: string,
@Body() body: { title: string; description: string, completed: boolean },
) {
const updatedTask = this.tasksService.updateTask(
Number(id),
body.title,
body.description,
body.completed,
);
return updatedTask;
}
}
We kunnen nu een PUT-request sturen naar /tasks/:id met een JSON-body die de nieuwe titel en beschrijving van de taak bevat. De controller zal deze data doorgeven aan de service om de taak bij te werken. Dit kunnen we testen met de tools die we eerder hebben besproken, zoals Postman of curl.
Wat er achterliggend gebeurt is dat de service de taak opzoekt op basis van de ID, en vervolgens de titel en beschrijving bijwerkt met de nieuwe waarden uit de request body.
Nadat je het PUT-request hebt gestuurd, kan je een GET-request doen naar /tasks/:id om te zien dat de taak is bijgewerkt.
DELETE-request
Tot slot willen we ook taken kunnen verwijderen. Hiervoor voegen we eerst een methode toe aan de service om een taak te verwijderen:
// src/tasks/tasks.service.ts
deleteTask(id: number): boolean {
const index = this.tasks.findIndex(task => task.id === id);
if (index !== -1) {
this.tasks.splice(index, 1);
return true;
}
return false;
}
Vervolgens voegen we een DELETE-route toe aan de controller om taken te verwijderen:
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Get()
getAllTasks() {
return this.tasksService.getAllTasks();
}
@Get(':id')
getTaskById(@Param('id') id: string) {
return this.tasksService.getTaskById(Number(id));
}
@Post()
createTask(@Body() body: { title: string; description: string }) {
const newTask = this.tasksService.createTask(body.title, body.description);
return newTask;
}
@Put(':id')
updateTask(
@Param('id') id: string,
@Body() body: { title: string; description: string, completed: boolean },
) {
const updatedTask = this.tasksService.updateTask(
Number(id),
body.title,
body.description,
body.completed,
);
return updatedTask;
}
@Delete(':id')
deleteTask(@Param('id') id: string) {
const result = this.tasksService.deleteTask(Number(id));
return { success: result };
}
}
We kunnen nu een DELETE-request sturen naar /tasks/:id om een taak te verwijderen. De controller zal deze request doorgeven aan de service om de taak te verwijderen. Dit kunnen we testen met de tools die we eerder hebben besproken, zoals Postman of curl. Je kan nadien een GET-request doen naar /tasks om te zien dat de taak is verwijderd.
DTO's
We hebben in de voorbeelden hierboven gebruik gemaakt van types voor de request bodies, zoals:
@Body() body: { title: string; description: string }
Dit werkt prima, en zorgt voor goede type-checking. Echter werkt dit enkel in compile-time. In run-time is er geen validatie van de data die binnenkomt. Om dit op te lossen kunnen we gebruik maken van DTO's (Data Transfer Objects). DTO's zijn een soort contracten die worden afgesproken tussen de front-end en de back-end over de structuur van de data die wordt uitgewisseld.
Als we bijvoorbeeld terugkijken naar de analogie van het restaurant, dan is een DTO vergelijkbaar met de manier waarop we onze bestelling doorgeven aan de ober. We geven duidelijk aan wat we willen bestellen, door de naam van het gerecht, eventuele aanpassingen en de hoeveelheid. We gaan niet om een pizza te bestellen meegeven dat we met de auto komen, of dat we een hond hebben. We geven enkel de relevante informatie door die nodig is om onze bestelling correct te verwerken.
Ook het omgekeerde geldt: Wanneer de ober onze maaltijd terugbrengt, verwachten we dat onze maaltijd op een bord wordt geserveerd, met bestek en een servet. We verwachten niet dat de ober ons een boodschappenlijstje geeft van alle ingrediënten die in onze maaltijd zijn gebruikt. We vertrouwen erop dat de ober onze bestelling correct heeft doorgegeven aan de keuken en dat we krijgen wat we hebben besteld.
In NestJS kunnen we DTO's definiëren als TypeScript-klassen. De reden dat dit klassen zijn en geen interfaces, is omdat ze in run-time ook gebruikt moeten kunnen worden. Daarnaast moet het ook extra functionaliteit kunnen bevatten, zoals validatie.
We kunnen bijvoorbeeld een DTO definiëren voor het aanmaken van een nieuwe taak:
// src/tasks/dto/create-task.dto.ts
export class CreateTaskDto {
title: string;
description: string;
}
Dit op zichzelf is nog niet voldoende om validatie te doen. We kunnen hiervoor de class-validator en class-transformer packages gebruiken. Deze kunnen we installeren via npm:
npm install class-validator class-transformer
Vervolgens kunnen we de DTO uitbreiden met validatie decorators:
// src/tasks/dto/create-task.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
description: string;
}
Nu hebben we enkele regels toegevoegd om de waarden te valideren:
@IsString(): Zorgt ervoor dat de waarde een string is.@IsNotEmpty(): Zorgt ervoor dat de waarde niet leeg is.
We kunnen ook sanitisatie toevoegen, bijvoorbeeld om ervoor te zorgen dat spaties aan het begin en einde van de string worden verwijderd:
import { IsNotEmpty, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateTaskDto {
@IsString()
@Transform(({ value }) => value.trim())
@IsNotEmpty()
readonly title: string;
@IsString()
@Transform(({ value }) => value.trim())
@IsNotEmpty()
readonly description: string;
}
Zo worden ook eventuele onnodige spaties verwijderd, voordat de validatie wordt uitgevoerd.
Om deze validatie te activeren, moeten we een globale validatie pipe toevoegen in main.ts:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 4000);
}
bootstrap();
Nu kunnen we de DTO gebruiken in onze controller:
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Post()
createTask(@Body() data: CreateTaskDto) {
const newTask = this.tasksService.createTask(data.title, data.description);
return newTask;
}
}
Nu zal NestJS automatisch de binnenkomende data valideren op basis van de regels die we hebben gedefinieerd in de CreateTaskDto. Als de data niet geldig is, zal NestJS een 400 Bad Request response terugsturen met details over de validatiefouten.
We willen ook een DTO maken voor het terugsturen van taken. Dit is niet strikt noodzakelijk, maar het kan handig zijn om een duidelijk contract te hebben over welke data we terugsturen naar de client. We kunnen bijvoorbeeld een TaskDto maken:
// src/tasks/dto/task.dto.ts
import { Expose, Exclude} from 'class-transformer';
@Exclude()
export class TaskDto {
@Expose()
readonly id: number;
@Expose()
readonly title: string;
@Expose()
readonly description: string;
@Expose()
readonly completed: boolean;
}
We gebruiken hier de @Exclude() decorator om ervoor te zorgen dat standaard alle velden worden uitgesloten van de serialisatie. Vervolgens gebruiken we de @Expose() decorator om expliciet aan te geven welke velden we willen opnemen in de response.
Soms bevat een entity of model meer velden dan we willen terugsturen naar de client. Bijvoorbeeld een User entity kan een passwordveld bevatten. In dat geval willen we niet dat dit veld wordt teruggestuurd in de response.
We kunnen dit oplossen door gebruik te maken van de @Exclude()decorator op het veld dat we willen uitsluiten.
import { Exclude, Expose } from 'class-transformer';
export class UserDto {
@Expose()
readonly id: number;
@Expose()
readonly username: string;
@Exclude()
readonly password: string;
}
Dit zorgt ervoor dat het password veld niet wordt opgenomen in de response wanneer we een UserDto instantie serialiseren. Het is een goede praktijk om echter altijd expliciet te definiëren welke velden we wel willen terugsturen, in plaats van welke we niet willen terugsturen. Dit doen we door gebruik te maken van de @Exclude() decorator op de klasse zelf, en vervolgens alleen de velden die we willen terugsturen te markeren met de @Expose() decorator. Dit zet standaard alle velden uit, en voorkomt dat we per ongeluk gevoelige informatie terugsturen.
import { Exclude, Expose } from 'class-transformer';
@Exclude()
export class UserDto {
@Expose()
readonly id: number;
@Expose()
readonly username: string;
}
Nu kunnen we deze DTO gebruiken in onze controller om de response te formatteren:
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { TaskDto } from './dto/task.dto';
import { plainToInstance } from 'class-transformer';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Post()
createTask(@Body() data: CreateTaskDto) {
const newTask = this.tasksService.createTask(data.title, data.description);
return plainToInstance(TaskDto, newTask);
}
}
We maken gebruik van de plainToInstance functie van class-transformer om het plain JavaScript object om te zetten naar een instantie van de TaskDto klasse. Dit zorgt ervoor dat de response voldoet aan het contract dat we hebben gedefinieerd in de DTO.
We kunnen dit ook toepassen op de andere methoden in de controller:
// src/tasks/dto/update-task.dto.ts
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class UpdateTaskDto {
@IsString()
@Transform(({ value }) => value.trim())
@IsNotEmpty()
readonly title: string;
@IsString()
@Transform(({ value }) => value.trim())
@IsNotEmpty()
readonly description: string;
@IsBoolean()
readonly completed: boolean;
}
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { TaskDto } from './dto/task.dto';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Get()
getAllTasks() {
const tasks = this.tasksService.getAllTasks();
return tasks.map((task) => plainToInstance(TaskDto, task));
}
@Get(':id')
getTaskById(@Param('id') id: string) {
const task = this.tasksService.getTaskById(Number(id));
return plainToInstance(TaskDto, task);
}
@Post()
createTask(@Body() data: CreateTaskDto) {
const newTask = this.tasksService.createTask(data.title, data.description);
return plainToInstance(TaskDto, newTask);
}
@Put(':id')
updateTask(
@Param('id') id: string,
@Body() data : UpdateTaskDto,
) {
const updatedTask = this.tasksService.updateTask(
Number(id),
data.title,
data.description,
data.completed,
);
return plainToInstance(TaskDto, updatedTask);
}
@Delete(':id')
deleteTask(@Param('id') id: string) {
const result = this.tasksService.deleteTask(Number(id));
return { success: result };
}
}
Wat gebeurt er nu concreet wanneer we een request sturen naar de back-end?
- Het request komt binnen in de controller.
- De controller zet de binnenkomende body om naar een instantie van de DTO-klasse (bijvoorbeeld
CreateTaskDto). - De validation pipe valideert de DTO op basis van de regels die we hebben gedefinieerd.
- De validation pipe laat de controller weten of de data geldig is of niet. Als de data niet geldig is, geeft de pipe een foutmelding terug en zal de controller een
400 Bad Requestresponse sturen. Als de data geldig is, gaat het verder naar de volgende stap. - De service verwerkt de data en voert de nodige acties uit (zoals het aanmaken van een nieuwe taak).
- De service stuurt het resultaat terug naar de controller.
- De controller zet het resultaat om naar een response DTO (bijvoorbeeld
TaskDto). - De controller stuurt de response DTO terug naar de client als JSON-response.
main.ts
In main.ts vindt de globale setup van de NestJS applicatie plaats, op basis van de root module (AppModule). Hier kunnen we globale middleware, pipes, filters en andere instellingen configureren die van toepassing zijn op de hele applicatie.
CORS
Een belangrijk aspect van back-end ontwikkeling is het omgaan met CORS (Cross-Origin Resource Sharing). CORS is een beveiligingsmechanisme dat bepaalt of een webpagina bronnen van een andere domein mag laden. Standaard blokkeren browsers cross-origin requests om beveiligingsredenen.
In main.ts kunnen we CORS inschakelen om ervoor te zorgen dat enkel onze front-end applicatie requests kan sturen naar onze back-end, zelfs als ze op verschillende domeinen draaien:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:4200']; // het domein van de front-end applicatie
})
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Hiermee staat onze back-end alleen requests toe van http://localhost:4200, wat het standaard domein is voor Angular applicaties die lokaal draaien. In een productieomgeving zouden we dit aanpassen naar het domein waar onze front-end applicatie gehost wordt. In een productieomgeving zou de origin bijvoorbeeld https://www.mijnapplicatie.com kunnen zijn.
global prefix
We kunnen ook een globale prefix instellen voor alle routes in onze applicatie. Dit is handig om een versiebeheer toe te passen op onze API, bijvoorbeeld door alle routes te prefixen met /api/v1:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:4200']
})
app.setGlobalPrefix('api/v1')
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Met deze instelling zullen alle routes in onze applicatie nu beginnen met /api/v1. Bijvoorbeeld, de route voor het ophalen van alle taken zal nu /api/v1/tasks zijn in plaats van gewoon /tasks. Dit helpt bij het organiseren van de API en maakt het eenvoudiger om verschillende versies van de API te beheren in de toekomst.
9. MongoDB
MongoDB is een NoSQL-database die document-geörienteerd is. In plaats van tabellen en rijen zoals in relationele databases, slaat MongoDB data op in flexibele JSON-achtige documenten. Dit maakt het zeer geschikt voor toepassingen waarbij de data-structuur kan variëren of snel kan evolueren. Het is zeer populair bij Node.js, NestJS en andere JavaScript-gebaseerde back-end frameworks vanwege de flexibiliteit en schaalbaarheid. Het is gebouwd voor performance en schaalbaarheid.
SQL vs NoSQL
Voordat we verder ingaan op MongoDB, is het belangrijk om het verschil te begrijpen tussen relationele databases (SQL) en NoSQL-databases. Relationele databases, zoals MySQ L, PostgreSQL en SQLite, slaan data op in tabellen met rijen en kolommen. Ze gebruiken SQL (Structured Query Language) voor het beheren en opvragen van data. Relationele databases zijn zeer geschikt voor toepassingen waarbij de data-structuur goed gedefinieerd is en relaties tussen verschillende data-entiteiten belangrijk zijn.
NoSQL (Not Only SQL) databases, zoals MongoDB, Cassandra en Redis, slaan data op in verschillende formaten, zoals documenten, key-value paren, grafen of kolom-georiënteerde opslag. Ze zijn ontworpen voor flexibiliteit en schaalbaarheid, en kunnen beter omgaan met grote hoeveelheden ongestructureerde of semi-gestructureerde data. Je kan het beste deze vergelijking raadplegen voor een diepgaandere uitleg over de verschillen tussen SQL en NoSQL databases.
Wanneer MongoDB gebruiken?
Wanneer wel:
- Als de data flexibel of evoluerend is en schema-aanpassingen nodig zijn.
- Voor toepassingen die veel met JSON werken (REST/GraphQL).
- Bij ongestructureerde of semi-gestructureerde data zoals logs, sensor data of gebruikerscontent.
- Als horizontale schaalbaarheid over meerdere servers belangrijk is.
- Voor snelle ontwikkeling en prototyping.
Wanneer niet:
- Voor complexe multi-step transacties die ACID-ondersteuning vereisen.
- Als strikte dataconsistentie noodzakelijk is.
- Bij sterk verbonden data met veel relaties, waar joins en foreign keys nodig zijn.
Installatie
Om MongoDB te gebruiken, moeten we eerst de MongoDB server installeren. Dit kan op verschillende manieren, afhankelijk van je besturingssysteem. De eenvoudigste manier is om MongoDB via Docker te draaien. Dit vereist dat je Docker op je machine hebt geïnstalleerd. Mocht je nog geen of minimale ervaring met docker hebben, is het aan te raden om de bare-metal installatie te volgen.
- Docker
- Bare-metal
Je vindt de installatie-instructies op de Officiële MongoDB website.
Als je de standaard installatie volgt, draait MongoDB op poort 27017. Deze heeft standaard geen volumes of authenticatie ingesteld, wat prima is voor lokale ontwikkeling, maar hou hier rekening mee. Dit wilt ook zeggen dat alle data verloren gaat wanneer je de container uitzet of verwijdert.
Om de mongoDB toch persistent te maken, kan je het run-commando uitbreiden met een volume:
docker run -d -p 27017:27017 --name mongodb -v mongodb:/data/DB
Dit zorgt ervoor dat de data wordt opgeslagen in een Docker volume genaamd mongodb, waardoor de data behouden blijft, zelfs als de container wordt verwijderd.
Je vindt de installatie-instructies voor de server op de officiële MongoDB website.
Volg de stappen en kies bij voorkeur de standaardinstellingen, tenzij je specifieke redenen hebt om hiervan af te wijken.
Zorg dat mongod draait op je machine. Dit is de MongoDB server die op de achtergrond draait en verbindingen accepteert. Je kiest best voor de standaard poort 27017, tenzij je een specifieke reden hebt om een andere poort te gebruiken.
We installeren daarnaast MongoDB Compass, een grafische interface om met MongoDB databases te werken. Dit maakt het eenvoudiger om data te bekijken en te beheren zonder command-line tools te hoeven gebruiken. Je kan de installatie-instructies voor MongoDB Compass vinden op de officiële MongoDB Compass website. De verbinding met de database is onafhankelijk van de installatie-methode. In beide gevallen is de standaard verbindingsstring:
mongodb://localhost:27017
We kunnen nu de MongoDB server starten (indien nog niet gebeurd) en verbinding maken via MongoDB Compass om te controleren of alles correct werkt.
concepten
Voordat we verder gaan met het gebruik van MongoDB, is het belangrijk om enkele basisconcepten te begrijpen:
| Begrip | Uitleg |
|---|---|
| Database | Een container voor gegevens. In MongoDB kunnen meerdere databases op dezelfde server bestaan. Elke database kan verschillende collecties bevatten. |
| Collectie | Vergelijkbaar met een tabel in relationele databases. Een groep van gerelateerde documenten. Wordt automatisch aangemaakt bij het eerste document. |
| Document | Een enkel record in een collectie. Wordt opgeslagen in BSON (Binary JSON). Documenten kunnen verschillende velden en datatypes bevatten en hoeven niet allemaal dezelfde structuur te hebben. |
| Field | Een sleutel-waarde paar binnen een document. Elk veld heeft een naam (sleutel) en een waarde, die verschillende datatypes kan hebben zoals string, nummer, boolean, array of genest document. |
| ObjectId | Uniek identificatienummer voor elk document. Wordt automatisch gegenereerd bij aanmaak en bevat o.a. een timestamp en unieke waarde. |
database
|--collection
|-- { _id: ObjectId, field1: value1, field2: value2, ... }
|-- { _id: ObjectId, field1: value1, field2: value2, ... }
bijvoorbeeld:
todo_db
|--tasks
|-- { _id: ObjectId("1"), title: "Task One", description: "This is task one" }
|-- { _id: ObjectId("2"), title: "Task Two", description: "This is task two" }
Mongo Shell
MongoDB wordt geleverd met een command-line interface genaamd mongo shell. Hiermee kunnen we direct verbinding maken met de MongoDB server en database-operaties uitvoeren via de command-line. Dit is handig voor snelle tests en beheer van de database.
Om de mongo shell te openen, kunnen we het volgende commando gebruiken in de terminal:
- Docker
- Bare-metal
- MongoDB Compass
docker exec -it mongodb mongosh
mongosh
Als je geen extra configuratie hebt gedaan, zal dit automatisch verbinding maken met de MongoDB server op localhost:27017.
Er zit ook een tab ingebouwd in MongoDB voor rechtsreekse shellcommando's. Dit kan je vinden als je op >_ drukt naast de verbinding die je hebt gemaakt aan de linkerkant.
Eenmaal in de mongo shell kunnen we verschillende commando's uitvoeren om databases en collecties te beheren. Hier zijn enkele basiscommando's:
| Commando | Beschrijving |
|---|---|
show dbs | Toont alle databases op de server. |
use <database> | Schakelt over naar de opgegeven database (maakt deze aan als deze nog niet bestaat). |
db | Toont de huidige database. |
db.createCollection('<collection>') | Maakt een nieuwe collectie aan in de huidige database. |
db.<collection>.insertOne({ ... }) | Voegt een nieuw document toe aan de opgegeven collectie. |
db.<collection>.updateOne({ ... }, { $set: { ... } }) | Werkt een document bij in de opgegeven collectie. |
db.<collection>.deleteOne({ ... }) | Verwijdert een document uit de opgegeven collectie. |
db.<collection>.drop() | Verwijdert de opgegeven collectie. |
db.<collection>.countDocuments() | Telt het aantal documenten in de opgegeven collectie. |
Zoeken (find)
| Commando | Beschrijving |
|---|---|
db.<collection>.find() | Haalt alle documenten op uit de opgegeven collectie. |
db.<collection>.find().pretty() | Haalt alle documenten op en formatteert de output voor betere leesbaarheid. |
db.<collection>.find({ <veld>: <waarde> }) | Haalt documenten op die voldoen aan de opgegeven filter. |
db.<collection>.find({ <veld>: { $regex: /<pattern>/ } }) | Haalt documenten op waarbij het veld voldoet aan het opgegeven reguliere expressiepatroon. |
db.<collection>.find().sort({ <field>: 1 }) | Haalt documenten op en sorteert ze oplopend op het opgegeven veld. Gebruik -1 voor aflopend sorteren. |
db.<collection>.find().limit(<number>) | Haalt een beperkt aantal documenten op uit de collectie. |
Je kan de volgende commando's testen:
show dbs
use tasks
db.createCollection('tasks')
db.tasks.insertOne({ title: 'Task One', description: 'This is task one'})
db.tasks.find()
db.tasks.updateOne({ title: 'Task One' }, { $set: { description: 'Updated description' } })
db.tasks.deleteOne({ title: 'Task One' })
db.tasks.find()
Je kan ook zoeken of filteren op basis van velden, bijvoorbeeld:
db.tasks.insertOne({ title: 'Task Two', description: 'This is task two'})
db.tasks.insertOne({ title: 'Task Three', description: 'This is task three'})
db.tasks.find({ title: 'Task Two' })
db.tasks.find({ description: { $regex: /three/ } })
db.tasks.find({ _id: ObjectId("...") }) // vervang "..." door de juiste ObjectId
Hier zien we CRUD-operaties ook terugkomen: Create (insertOne), Read (find), Update (updateOne) en Delete (deleteOne).
relaties
In mongoDB zijn er geen traditionele joins zoals in relationele databases. In plaats daarvan kunnen we relaties tussen documenten op twee manieren modelleren: embedded documents en references.
Embedded documents
Embedded documents zijn documenten die zijn genest binnen een ander document. Dit is handig voor one-to-many relaties waarbij de gerelateerde data vaak samen wordt opgevraagd. Bijvoorbeeld, als we een taak hebben met meerdere opmerkingen, kunnen we de opmerkingen als een array van geneste documenten binnen het taakdocument opslaan:
{
_id: ObjectId("..."),
title: "Task One",
description: "This is task one",
comments: [
{ text: "This is the first comment", date: ISODate("2024-01-01T00:00:00Z") },
{ text: "This is the second comment", date: ISODate("2024-01-02T00:00:00Z") }
]
}
References
References zijn verwijzingen naar documenten in andere collecties. Dit is handig voor many-to-many relaties of wanneer de gerelateerde data niet vaak samen wordt opgevraagd. Bijvoorbeeld, als we gebruikers hebben die taken kunnen toewijzen, kunnen we een referentie naar de gebruiker opslaan in het taakdocument:
{
_id: ObjectId("..."),
title: "Task One",
description: "This is task one",
assignedTo: ObjectId("...") // verwijzing naar een gebruiker in de users collectie
}
Bij het gebruik van references moeten we vaak meerdere queries uitvoeren om de gerelateerde data op te halen. Bijvoorbeeld, om de gebruiker op te halen die aan een taak is toegewezen, moeten we eerst de taak opvragen en vervolgens de gebruiker op basis van de assignedTo referentie.
BSON
BSON (Binary JSON) is het opslagformaat dat MongoDB gebruikt om documenten op te slaan. het is een binaire representatie van JSON-achtige documenten. BSON ondersteunt meer datatypes dan JSON, zoals Date, Decimal128 en Binary, wat het geschikter maakt voor opslag in een database.
indexatie
Indexes worden gebruikt om snel te kunnen zoeken in grote datasets. Je kunt dit beschouwen als een inhoudsopgave in een boek, waarmee je snel naar de juiste pagina kunt springen zonder het hele boek te hoeven doorzoeken.
Automatisch maakt MongoDB een index aan op het _id veld van elke collectie, wat zorgt voor snelle zoekopdrachten op basis van de unieke identificator van documenten. Daarnaast kun je handmatig extra indexes aanmaken op andere velden om zoekprestaties te verbeteren.
db.tasks.createIndex({ title: 1}) // 1 voor oplopend, -1 voor aflopend
Wat gebeurt er nu achter de schermen wanneer we een index maken? MongoDB bouwt een datastructuur die de waarden van het opgegeven veld (in dit geval title) koppelt aan de locaties van de bijbehorende documenten in de collectie. Hierdoor, wanneer we een query uitvoeren met find({ title: 'Task One' }), gaat MongoDB niet door alle documenten in de collectie, maar gebruikt het de index om snel de locaties van de documenten met die titel te vinden. Hierdoor is het aanzienlijk sneller.
Het is echter niet verstandig om op elk veld een index te maken, omdat dit extra opslagruimte vereist. Je kan het zien als elke keer dat er een document wordt toegevoegd of bijgewerkt, de index ook moet worden bijgewerkt. Dit kan de prestaties van schrijfoperaties beïnvloeden. Daarom is het belangrijk om alleen indexes te maken op velden die vaak worden gebruikt in zoekopdrachten.
Mongoose
We kunnen de Mongo Shell commando's niet rechtstreeks gebruiken in onze NestJS applicatie. Hiervoor hebben we een ODM (Object Document Mapper) library nodig. Dit is een library die ons toestaat om de datastructuren van mongoDB te definiëren als objecten in onze code, en deze objecten te gebruiken. De populairste ODM voor Node.js en NestJS is Mongoose. Mongoose biedt een schema-gebaseeerde oplossing om onze data-modellen te definiëren.
We hebben eerder gezien dat MongoDB een flexibel schema heeft, en dus geen vaste structuur vereist voor documenten. Echter gaan we door Mongoose toch schema's definiëren. Dit is belangrijk, omdat we anders in de code niet weten welke velden we kunnen verwachten in de documenten. Het gebruik van deze schema's is ook niet noodzakelijk een beperking: in grotere toepassingen is het vaak wenselijk om een zekere mate van structuur en validatie te hebben op de data die we opslaan. Daarnaast kunnen we via Mongoose wel nog in zekere mate flexibele data oplsaan, zoals optionele velden of geneste documenten.
We installeren Mongoose via npm:
npm install mongoose @nestjs/mongoose
Vervolgens kunnen we Mongoose integreren in onze NestJS applicatie. We beginnen met het importeren van de MongooseModulein onze AppModule:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TasksModule } from './tasks/tasks.module';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
TasksModule,
MongooseModule.forRoot('mongodb://localhost:27017/todo-db'),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
WE gebruiken hierin de forRootmethode van Mongoose. Hier geven we de database-string door om verbinding te maken met de MongoDB server.
We gebruiken als database-string hier:
mongodb://localhost:27017/todo-db
Wat betekent dit nu juist?
mongodb://geeft aan welke soort database we gebruiken, in dit geval MongoDB.localhost:27017. Het kan ook zijn dat dit op een andere hostnaam of IP-adres staat, bijvoorbeeld als de database op een andere server draait. De27017is de poort waarop de MongoDB server luistert. Standaard is dit27017, maar dit kan aangepast worden in de MongoDB configuratie.todo-dbis de naam van de database waarmee we verbinding maken. Als deze database nog niet bestaat, zal MongoDB deze automatisch aanmaken wanneer we de eerste keer data opslaan.
We maken hier uitzonderlijk verbinding zonder authenticatie. In een productieomgeving is het echter sterk aangeraden om authenticatie in te schakelen en een gebruiker aan te maken met de juiste rechten voor de database. Dan zou de database-string er bijvoorbeeld zo uitzien:
mongodb://username:password@localhost:27017/todo-db
We moeten nu starten met het definiëren van een schema voor onze taken. Voor de eenvoudigheid werken we voorlopig met één enkel schema. We maken een nieuwe map schemas aan in de tasksmodule:
// src/tasks/schemas/task.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class Task {
@Prop({ required: true})
title: string;
@Prop({ required: true})
description: string;
@Prop({ default: false})
completed: boolean;
}
export const TaskSchema = SchemaFactory.createForClass(Task);
Hier zien we veel nieuwe concepten, dus laten we het even stap voor stap overlopen:
Om te beginnen importeren we enkele decorators en types van @nestjs/mongoose en mongoose. De belangrijkste zijn:
@Schema(): Deze decorator markeert de klasse als een Mongoose schema.@Prop(): Deze decorator markeert een eigenschap van de klasse als een veld in het schema.SchemaFactory: Dit is een helper om een Mongoose schema te maken op basis van de klasse.
@Schema()
export class Task {
@Prop({ required: true})
title: string;
@Prop({ required: true})
description: string;
@Prop({ default: false})
completed: boolean;
}
Dit is de eigenlijke schema-definitie. Hier zeggen we programmatisch tegen Mongodb hoe een taak eruitziet in de database:
- We definiëren een klasse
Tasken markeren deze met de@Schema()decorator. - We definiëren drie eigenschappen:
title,descriptionencompleted, en markeren deze met de@Prop()decorator. - We geven extra opties door aan de
@Prop()decorator, zoalsrequired: trueom aan te geven dat deze velden verplicht zijn, endefault: falseom een standaardwaarde in te stellen voor hetcompletedveld. - Daarnaast geven we de types van de eigenschappen op (string voor
titleendescription, boolean voorcompleted).
export const TaskSchema = SchemaFactory.createForClass(Task);
Tot slot gebruiken we de SchemaFactory om een Mongoose schema te maken op basis van de Task klasse. Dit schema kunnen we vervolgens gebruiken om een Mongoose model te maken dat we in onze service kunnen gebruiken om met de database te communiceren.
We moeten nu het schema registreren in de TasksModule, zodat we het kunnen gebruiken in de service:
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { MongooseModule } from '@nestjs/mongoose';
import { Task, TaskSchema } from 'src/tasks/schemas/task.schema';
@Module({
imports: [
MongooseModule.forFeature([{ name: Task.name, schema: TaskSchema }]),
],
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
We gebruiken de MongooseModule.forFeature methode om het Task schema te registreren. We geven een array van objecten door, waarbij elk object de naam van het model en het bijbehorende schema bevat. Dit maakt het mogelijk om het Task model te injecteren in onze service.
Als we nu nestjs al een keer starten, zal het automatisch de todo-db database aanmaken in MongoDB (indien deze nog niet bestaat). Daarnaast zal het ook een tasks collectie aanmaken op basis van het Task schema dat we hebben gedefinieerd.
Hoe weet Mongoose nu welke naam het moet gebruiken voor de collectie in de database? Standaard gebruikt Mongoose de meervoudsvorm van de modelnaam in kleine letters. In ons geval is de modelnaam Task, dus zal Mongoose automatisch de collectie tasksaanmaken. Dit kan je aanpassen door een derde parameter door te geven aan de @Schema() decorator, bijvoorbeeld:
@Schema({ collection: 'my_custom_tasks' })
Voorlopig is dit voor onze toepassing niet nodig, maar het is goed om te weten dat deze optie bestaat.
Create
Nu kunnen we het Task model gebruiken in onze TasksService om CRUD-operaties uit te voeren op de taken in de MongoDB database. We starten hierbij met Create.
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Task } from './schemas/task.schema';
@Injectable()
export class TasksService {
constructor(@InjectModel(Task.name) private taskModel: Model<Task>) {}
createTask(title: string, description: string): Promise<Task> {
const newTask = new this.taskModel({
title: title,
description: description,
});
return newTask.save();
}
}
Merk op dat we hier enkele nieuwe concepten zien:
- We hebben onze oorspronkelijke interface van
Taskvervangen door hetTaskmodel dat we hebben gedefinieerd met Mongoose. Aangezien dat dit een klasse is, kunnen we dit ook gebruiken als type in onze service. - We injecteren het
Taskmodel in de constructor van de service met behulp van de@InjectModel()decorator. Dit maakt het mogelijk om het model te gebruiken om met de database te communiceren. - In de
createTaskmethode maken we eeen nieuwe taak aan door een nieuwe instantie van hettaskModelte maken en detitleendescriptiondoor te geven. We moetencompletedniet doorgeven, omdat deze standaard opfalsestaat. - Wanneer we
newTask.save()dan aanroepen, gaat Mongoose de inhoud van de instantie overlopen en een nieuw document aanmaken in detaskscollectie van detodo-dbdatabase in MongoDB.
De parameters van de createTask methode blijven hetzelfde, dus hoeven we in de controller niet aan te passen, maar merk op: we sturen nu een Promise<Task> terug in plaats van Task. Dit komt omdat Mongoose asynchroon werkt en we moeten wachten tot de database-operatie is voltooid voordat we het resultaat kunnen teruggeven.
We moeten de controller dus ook aanpassen om hiermee om te gaan:
// src/tasks/tasks.controller.ts
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Post()
async createTask(@Body() data: CreateTaskDto) {
const newTask = await this.tasksService.createTask(
data.title,
data.description,
);
return plainToInstance(TaskDto, newTask);
}
}
We kunnen nu een POST-request sturen naar de /tasks endpoint om een nieuwe taak aan te maken. We sturen de volgende JSON-body mee:
{
"title": "Nieuwe taak",
"description": "Beschrijving van de nieuwe taak"
}
Als je de dto's hebt geïmplementeerd zoals eerder beschreven, zal je een response terugkrijgen die er zo uitziet:
{
"title": "Nieuwe taak",
"description": "Beschrijving van de nieuwe taak",
"completed": false
}
Waarom zien we het _id veld niet terug? We hebbben in de dto eerder gedefinieerd dat we hetid veld willen gebruiken, maar niet het _id veld. Wanneer we een term proberen te gebruiken die niet bestaat in de DTO, wordt deze automatisch weggelaten in de response. We kunnen dit oplossen door het idveld te vervangen door _id in de TaskDto:
// src/tasks/dto/task.dto.ts
import { Exclude, Expose } from 'class-transformer';
@Exclude()
export class TaskDto {
@Expose()
readonly _id: number;
@Expose()
readonly title: string;
@Expose()
readonly description: string;
@Expose()
readonly completed: boolean;
}
Als je nu opnieuw een POST-request stuurt, zal de response er zo uitzien:
{
"_id": "64b8f0c2e1b2c3d4e5f67890",
"title": "Nieuwe taak",
"description": "Beschrijving van de nieuwe taak",
"completed": false
}
We kijken nu in de database om te zien of de taak correct is opgeslagen.
todo-db> db.tasks.find()
[
{
_id: ObjectId('6957c761b6baaf0c16cc4c38'),
title: 'Nieuwe taak',
description: 'Beschrijving van de nieuwe taak',
completed: false,
__v: 0
},
{
_id: ObjectId('6957c793cd555e9308adbcfa'),
title: 'Nieuwe taak',
description: 'Beschrijving van de nieuwe taak',
completed: false,
__v: 0
}
]
We zien hier inderdaad de twee taken terugkomen die we hebben aangemaakt via de API. Mongoose heeft automatisch het _id veld toegevoegd als unieke identificator voor elk document. Daarnaast heeft Mongoose ook een __v veld toegevoegd, wat de versie van het document bijhoudt. Dit is handig voor interne doeleinden, zoals bij het uitvoeren van updates.
Read
We kunnen nu de getAllTasksen getTaskById methoden implementeren in de service om taken op te halen uit de database:
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Task } from './schemas/task.schema';
@Injectable()
export class TasksService {
constructor(@InjectModel(Task.name) private taskModel: Model<Task>) {}
createTask(title: string, description: string): Promise<Task> {
const newTask = new this.taskModel({
title: title,
description: description,
});
return newTask.save();
}
getAllTasks(): Promise<Task[]> {
const tasks = this.taskModel.find().exec();
return tasks;
}
getTaskById(id: string): Promise<Task | null> {
const task = this.taskModel.findById(id).exec();
return task;
}
}
We maken gebruik van de Mongoose methoden find() en findById()om respecievelijk alle taken op te halen en een taak op basis van het _id veld. Beide methoden retourneren een query-object. We zouden hier nog extra queries kunnen toevoegen, zoals filters, sortering of paginatie. Om de query daadwerkelijk uit te voeren en de resultaten op te halen, roepen we de exec() methode aan. Dit retourneert een Promise die we kunnen awaiten of teruggeven.
In de controller passen we terug de methoden aan om de resultaten asynchroon op te halen:
// src/tasks/tasks.controller.ts
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Post()
async createTask(@Body() data: CreateTaskDto) {
const newTask = await this.tasksService.createTask(
data.title,
data.description,
);
return plainToInstance(TaskDto, newTask);
}
@Get()
async getAllTasks() {
const tasks = this.tasksService.getAllTasks();
return tasks.then((taskList) =>
taskList.map((task) => plainToInstance(TaskDto, task)),
);
}
@Get(':id')
async getTaskById(@Param('id') id: string) {
const task = await this.tasksService.getTaskById(id);
return plainToInstance(TaskDto, task);
}
}
Merk hier op dat de id van datatype is veranderd. We gebruiken hier string in plaats van number. Dit komt omdat het _id veld in MongoDB een ObjectId is, wat een 24-karakter hexadecimale string is. Daarom moeten we de id als string behandelen in onze code.
Nu we dit hebben geïmplementeerd, kunnen we een GET-request sturen naar het /tasks endpoint om alle taken op te halen, en naar het /tasks/:id endpoint om een specifieke taak op te halen op basis van het _id veld.
Update
We kunnen nu de UpdateTask methode implementeren in de service om een taak bij te werken in de database. Zo kunnen we bijvoorbeeld de taak als voltooid markeren:
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Task } from './schemas/task.schema';
@Injectable()
export class TasksService {
constructor(@InjectModel(Task.name) private taskModel: Model<Task>) {}
createTask(title: string, description: string): Promise<Task> {
const newTask = new this.taskModel({
title: title,
description: description,
});
return newTask.save();
}
getAllTasks(): Promise<Task[]> {
const tasks = this.taskModel.find().exec();
return tasks;
}
getTaskById(id: string): Promise<Task | null> {
const task = this.taskModel.findById(id).exec();
return task;
}
updateTask(id: string, updatedData: Partial<Task>): Promise<Task | null> {
return this.taskModel
.findByIdAndUpdate(id, updatedData, { new: true })
.exec();
}
}
We zien hier een nieuw concept: Partial<T>
Dit is een Typescript utility type dat een nieuw type maakt waarbij alle eigenschappen van het oorspronkelijke type optioneel zijn. In ons geval betekent dit dat we een object kunnen doorgeven met slechts enkele velden van het Task type, en niet alle velden verplicht hoeven te specificeren. Stel dat we dit type manueel zouden definiëren, dan zou het er zo uitzien:
type PartialTask = {
title?: string;
description?: string;
completed?: boolean;
}
Dit is handig voor update-operaties, omdat we vaak slechts enkele velden willen bijwerken zonder de hele taak te hoeven overschrijven.
Mongoose biedt de findByIdAndUpdate methode om een document bij te werken op basis van het _id veld. We geven de id, de bijgewerkte data en een optie { new: true } door om aan te geven dat we het bijgewerkte document willen terugkrijgen in plaats van het originele document. We roepen opnieuw exec() aan om de query uit te voeren en een Promise terug te krijgen.
In de controller passen we de updateTask methode aan om de update-operatie asynchroon uit te voeren:
// src/tasks/tasks.controller.ts
@Put(':id')
async updateTask(
@Param('id') id: string,
@Body() data: UpdateTaskDto,
) {
const updatedTask = await this.tasksService.updateTask(id, data);
return plainToInstance(TaskDto, updatedTask);
}
Merk op dat we in de updateTaskmethode in de service gebruik hebben gemaakt van Partial<Task>, terwijl we in de controller een UpdateTaskDto gebruiken. We geven dus in dit geval alle wijzigbare velden door via de DTO. Dit is omdat we gebruik maken van een PUT endpoint. Een PUTrequest verwacht dat we het volledige object doorgeven, ook al zijn sommige velden ongewijzigd.
Waarom gebruiken we dan Partial<Task> in de service? Dit is omdat we in de service niet willen afdwingen dat alle velden aanwezig zijn. Zo kunnen we de service ook hergebruiken voor andere doeleinden, zoals enkel het aanduiden dat een taak voltooid is, zonder de andere velden te hoeven doorgeven.
Stel dat we toch enkel de velden willen doorgeven die gewijzigd zijn, dan zouden we beter een PATCH endpoint gebruiken in plaats van PUT. In dat geval zou de controller er zo uitzien:
@Patch(':id')
async patchTask(
@Param('id') id: string,
@Body() data: Partial<UpdateTaskDto>,
) {
const updatedTask = await this.tasksService.updateTask(id, data);
return plainToInstance(TaskDto, updatedTask);
}
Maar dit valt buiten het bestek van deze cursus.
Delete
Tot slot kunnen we de deleteTask methode implementeren in de service om een taak te verwijderen uit de database:
// src/tasks/tasks.service.ts
deleteTask(id: string): Promise<Task | null> {
return this.taskModel.findByIdAndDelete(id).exec();
}
We gebruiken de Mongoose methode findByIdAndDelete om een document te verwijderen op basis van het _id veld. We roepen opnieuw exec() aan om de query uit te voeren en een Promise terug te krijgen. In de controller passen we de deleteTask methode aan om de delete-operatie asynchroon uit te voeren:
// src/tasks/tasks.controller.ts
@Delete(':id')
async deleteTask(@Param('id') id: string) {
const deletedTask = await this.tasksService.deleteTask(id);
return plainToInstance(TaskDto, deletedTask);
}
We kunnen nu een DELETE-request sturen naar het /tasks/:id endpoint om een taak te verwijderen op basis van het _id veld.
Je hebt nu een volledige CRUD-functionaliteit geïmplementeerd in je NestJS applicatie met MongoDB als database. Je kan nu via de REST API taken aanmaken, ophalen, bijwerken en verwijderen in de MongoDB database. Je kan nu je front-end hieraan koppelen om een volledige to-do applicatie te maken.
Het volgende onderdeel behoort niet meer tot de basiskennis van MongoDB en CRUD, maar het is wel nuttig om te weten hoe we relaties kunnen modelleren in MongoDB met Mongoose.
Relaties met Mongoose
We hebben eerder besproken hoe we relaties kunnen modelleren in MongoDB met embedded documents en references. Laten we nu bekijken hoe we dit kunnen implementeren met Mongoose in onze NestJS applicatie.
Embedded documents met Mongoose
We kunnen embedded documents modelleren door een sub-schema te definiëren binnen ons hoofd-schema. Laten we bijvoorbeeld een Comment sub-schema maken binnen het Task schema om opmerkingen bij een taak op te slaan:
// src/tasks/schemas/comment.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class Comment {
@Prop({ required: true})
text: string;
}
export const CommentSchema = SchemaFactory.createForClass(Comment);
Vervolgens kunnen we dit sub-schema gebruiken in het Task schema:
// src/tasks/schemas/task.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Comment, CommentSchema } from './comment.schema';
@Schema()
export class Task {
@Prop({ required: true})
title: string;
@Prop({ required: true})
description: string;
@Prop({ default: false})
completed: boolean;
@Prop({ type: [CommentSchema], default: [] })
comments: Comment[];
}
export const TaskSchema = SchemaFactory.createForClass(Task);
We definiëren een comments veld in het Task schema als een array van Comment sub-documenten. We geven het type op als [CommentSchema] en stellen de standaardwaarde in op een lege array.
Nu kunnen we opmerkingen toevoegen aan een taak door nieuwe Comment sub-documenten toe te voegen aan de comments array:
const task = await this.taskModel.findById(taskId).exec();
task.comments.push({ text: 'This is a comment' });
await task.save();
References met Mongoose
We kunnen references modelleren door het _id veld van een ander document op te slaan als een referentie. Laten we bijvoorbeeld een User schema maken en een referentie naar de gebruiker opslaan in het Task schema:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class User {
@Prop({ required: true})
name: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
Vervolgens kunnen we een referentie naar de User opslaan in het Task schema:
// src/tasks/schemas/task.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { User } from './user.schema';
@Schema()
export class Task {
@Prop({ required: true})
title: string;
@Prop({ required: true})
description: string;
@Prop({ default: false})
completed: boolean;
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'User' })
assignedTo: User;
}
export const TaskSchema = SchemaFactory.createForClass(Task);
We definiëren een assignedTo veld in het Task schema als een referentie naar het User schema. We geven het type op als mongoose.Schema.Types.ObjectId en gebruiken de ref optie om aan te geven naar welk schema we verwijzen.
Nu kunnen we een gebruiker toewijzen aan een taak door het _id veld van de gebruiker op te slaan in het assignedTo veld:
const user = await this.userModel.findById(userId).exec();
const task = await this.taskModel.findById(taskId).exec();
task.assignedTo = user._id;
await task.save();
Wanneer je een nieuw schema aanmaakt, zoals Comment of User, vergeet dan niet om deze ook te registreren in de TasksModule (of een aparte module indien nodig) met MongooseModule.forFeature.