1. Rest API's
1. Rest API's
In deze les wordt besproken wat een REST API is en hoe we een eenvoudige API kunnen implementeren in Node. Deze les blijven we bij in-memory data, wat betekent dat we alle data kwijt zijn nadat de API herstart wordt, vanaf volgende les voegen we persistentie toe aan de hand van een SQL of document database.
We demonstreren de concepten aan de hand van een API waarmee we de campussen van Thomas More kunnen beheren.
REST
Een REST (representational state transfer) API is een architecturaal patroon voor web API's. Het is geen framework, programmeertaal, standaard, of protocol en kan dus geïmplementeerd worden in elke programmeertaal. Verder zijn er ook geen limieten op de structuur van de teruggegeven data, deze kan als JSON, HTML, Plain Text, XML, ... teruggegeven worden. JSON is wel veruit het meest populaire formaat.
Omdat REST geen echte standaard is, zijn de vereisten voor een REST api ook niet sterk afgebakend. Een REST API moet voldoen aan volgende vereisten[1]:
Uniforme interface:
Alle API requests voor een resource (e.g. een rij in een tabel, een document in een document database) geven de data op dezelfde manier terug. Dit wil zeggen dat de volledige API ofwel in JSON-formaat
ofwel in XML-formaat (of nog iets anders) aangeboden kan worden, maar geen mix van beiden. Het gekozen formaat hoeft niet overeen te komen met het formaat dat intern door de server gebruikt wordt.De resource die teruggegeven wordt door de API bevat alle nodige informatie om de resources aan te passen of te verwijderen.
Elke resource is uniek en kan met één URL geïdentificeerd worden.
Hypermedia as the engine of application state (HATEOAS): De resources moeten een overzicht geen van welke andere resources er beschikbaar zijn aan de hand van hyperlinks. Net zoals bij een browser moet een er, bij de een correcte HATEOAS implementatie slechts één URL gekend zijn, de andere kunnen bezocht worden door "door te klikken" in het antwoord dat de server geeft op een request voor de root-url.
HATEOAS wordt echter weinig gebruikt in productie omdat de meeste API's bedoeld zijn voor programmeurs en CRUD-operaties moeten ondersteunen. Omdat er op verschillende pagina's in de client-applicatie verschillende acties ondersteund moeten worden en omdat deze pagina's rechtstreeks bezocht kunnen worden (in de plaats van via de root pagina te gaan), moet de applicatie de URL's van de API sowieso kennen. Voor een read-only API kan HATEOAS wel nuttig zijn. Een goed voorbeeld hiervan is de Star Wars API.
Client-server met zwakke coupling[2]: De client en server moeten zo weinig mogelijk van elkaar weten, de client en server kunnen onafhankelijk van elkaar ontwikkeld worden en communiceren enkel via HTTP(S).
Statelessness: Elk request van de client naar de server moet alle informatie bevatten om het request correct af te handelen. De server mag geen data bewaren over de state op de client, er zijn dus geen server-side sessions.
Caching: Waar mogelijk moeten resources in de gecached worden. Elke antwoord op een request moet informatie bevatten die aangeeft of een resource client-side gecached mag worden of niet.
Layered system architecture: Noch de client, noch de server mag er van uitgaan dat de communicatie tussen client en server rechtstreeks gebeurd. Deze communicatie kan eventueel via een derde partij gaan. Zou is het bijvoorbeeld mogelijk dat een verzoek als volgt afgehandeld wordt: Client ↔ Authentication/Authorization server ↔ API server. Voor de client lijkt het alsof deze rechtstreeks communiceert met de API server.
Deze architectuur maakt het mogelijk om, op elk moment, een proxy of load balancer toe te voegen tussen de client en de server. De client merkt hier niets van.
REST Requests
Voor elke resource zijn CRUD-operaties beschikbaar en met elke CRUD-operatie komt een HTTP-methode overeen.
| CRUD-operatie | HTTP-methode |
|---|---|
| Create | POST |
| Read | GET |
| Update | PUT |
| Delete | DELETE |
Elk request naar de API heeft vier onderdelen:
- Operatie: Een HTTP-methode.
- Endpoint: Het laatste deel van URL, voor onze API dus /api/*.
- Parameters: Data die de door de API gebruikt wordt om het request af te handelen.
- Header: HTTP-headers die zaken zoals authentication data bevatten.
Afhankelijk van de methode worden er parameter toegevoegd in de body of worden er een parameter toegevoegd in de URL. Voor PUT en POST wordt body data gebruikt en voor GET, PUT en DELETE een URL-parameter.
Node & Express
In deze cursus bouwen we API's via Node.js en met behulp van het Express framework. Express is een minimalistisch webserver-framework voor Node, hiermee definiëren we dus de routes die via onze API beschikbaar zijn.
In frontend en backend frameworks hebben we een template gebruikt om van te starten, maar omdat de projecten voor deze cursus veel minder libraries vereisen en minder complex zijn, zullen we de projecten vanaf nul opbouwen.
We vertrekken voor de rest van deze les uit startbestanden. Onderstaande commando's worden uitgevoerd in de api map van de startbestanden.
Startbestanden
Project aanmaken
Onderstaand commando maakt een nieuwe package.json aan in de map waarin het commando uitgevoerd wordt (in tegenstelling tot backend en frontend wordt er dus geen nieuwe map aangemaakt.) We voegen één lijn code toe aan package.json zodat we met ESM code (import/export) kunnen werken.
{
"name": "mobile_lecture1_example",
"version": "1.0.0",
"description": "",
"main": "app.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}Het nieuwe bestand bevat bitter weinig, enkel de absoluut noodzakelijke velden zijn aanwezig, we zullen dus nog enkele dingen moeten toevoegen. De meta-data zoals description, keywords, author en license kunnen onaangepast laten, deze zijn tenslotte niet zinvol in een leeromgeving, maar worden pas belangrijk als we een echte (open-source) applicatie bouwen.
De eerste aanpassing die we doen is het installeren van de verschillende libraries die we gaan gebruiken. Zoals eerder vermeld gebruiken we Express om de webserver op te bouwen, daarnaast installeren we ook TypeScript en ESLint. Voor ESLint zijn heel wat plug-ins vereist en voor Express moeten we de TypeScript bindings ook installeren.
Zowel TypeScript als ESLint moeten geconfigureerd worden, we doorlopen hieronder de stappen voor beide tools en beginnen met ESLint.
ESLint verwacht dat de root folder van het project een bestand bevat waarvan de naam begint met .eslintrc, de extensie kan .js, cjs, .yaml of .json zijn, hieronder gebruiken we een JSON-bestand. Hieronder worden twee configuraties aangeboden, de eerste is de absolute minimumconfiguratie, de tweede is de configuratie die we in de voorbeelden en oplossingen van frontend frameworks, backend frameworks en mobile development gebruiken. Je bent zelf vrij om te kiezen welke configuratie je gebruikt.
De opties die we gebruiken in de minimumconfiguratie doen het volgende:
env: Welke environments gebruikt worden in de code, de volledige lijst van opties is te vinden in de documentatie.extends: De configuraties waarvan overgeërfd wordt. De eslint:recommended configuratie definieert een klein aantal regels die linting activeren voor enkele veelgebruikte en welgekende best-practices, plugin:@typescript-eslint/recommended doet hetzelfde, maar dan specifiek voor TypeScript.plugins: Laad uitbreiding op de standaard features van ESLint.parser: De tool die gebruikt wordt om een TypeScript-bestand in te lezen zodat het verwerkt kan worden door de linter. Omdat TypeScript een hele hoop syntax-extensies toevoegt kan de standaard ESLint parser TypeScript code niet correct correct verwerken.root: Geef aan dat ESLint niet hoger in de mappenstructuur mag zoeken naar configuraties.
{
"env": {
"es2022": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"root": true,
"rules": {
}
}{
"env": {
"es2022": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"root": true,
"rules": {
"quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline"
}
],
"semi": [
"error",
"never"
]
}
}Ook voor TypeScript is een configuratiefile nodig, dit keer moet de naam tsconfig.json zijn. We gebruiken onderstaande configuratie waarvoor de basis gegenereerd wordt met het commando pnpm exec tsc --init. We passen enkele opties aan om het verdere verloop van de cursus eenvoudiger te maken:
target: We zetten deze optie op "es2017", dit is de minimumvereiste om top-level (niet in een functie) awaits te kunnen gebruiken.lib: We voegen ES2022 toe in deze array zodat we de recente nieuwe features binnen JavaScript kunnen gebruiken.module: Door deze optie op ES2022 te zetten wordt de TypeScript code gecompileerd naar code die gebruik maakt van ESM syntax (import/export).moduleResolution: Omdat we ESM code genereren moeten we deze optie op nodenext zetten, anders verwacht Node CommonJS code in de plaats van ESM code.outDir: Deze optie geeft aan waar de gecompileerde JavaScript code te staan komt. We kiezen hier voor de map dist zodat er een duidelijk onderscheid is tussen de TypeScript code en de JavaScript code die hieruit gecompileerd wordt.noImplicitAny: Door deze optie op true te zetten mag het any type niet gebruikt.
De volledige lijst van beschikbare opties is te raadplegen in de TypeScript documentatie.
{
"compilerOptions": {
// Set the JavaScript language version for emitted JavaScript and include compatible library declarations.
"target": "es2017",
// Specify a set of bundled library declaration files that describe the target runtime environment.
"lib": [
"es2022"
],
// Specify what module code is generated.
"module": "ES2022",
// Ensure that Node can handle the compiles ESM code.
"moduleResolution": "nodenext",
// Specify an output folder for all emitted files.
"outDir": "./dist",
// Emit additional JavaScript to ease support for importing CommonJS modules.
// This enables 'allowSyntheticDefaultImports' for type compatibility.
"esModuleInterop": true,
// Ensure that casing is correct in imports.
"forceConsistentCasingInFileNames": true,
// Enable all strict type-checking options.
"strict": true,
// Enable error reporting for expressions and declarations with an implied 'any' type.
"noImplicitAny": true,
// Skip type checking all .d.ts files (third-party libraries).
"skipLibCheck": true,
// Allow importing JSON files as a module.
"resolveJsonModule": true,
},
// Files that the TypeScript compiler should convert.
"include": [
"./src/**/*.ts"
]
}Scripts
De laatste stap voordat we de API kunnen ontwikkelen is het toevoegen van enkele scripts in package.json. Voordat we dit doen, moeten we nog een library installeren.
De TypeScript code moet gecompileerd worden naar JavaScript code die begrijpbaar is voor Node. We kunnen hiervoor het commando pnpm exec tsc gebruiken om de TypeScript code te compileren naar JavaScript en dan kan deze code vervolgens uitgevoerd worden via node ./dist/app.js. Dit heeft echter het nadeel dat we de TypeScript code manueel opnieuw moeten compileren nadat er wijzigingen doorgevoerd zijn en dat we ook de development server opnieuw moeten opstarten.
We kunnen dit probleem oplossen via tsx. Deze library compileert de TypeScript code en start de development server automatisch.
Natuurlijk moeten we voor een productie-build nog steeds de tsc en node ./dist/app.js commando's gebruiken omdat tsx te veel overhead heeft voor production.
{
"name": "mobile_lecture1_example",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"dev": "tsx --watch ./src/app.ts",
"build": "tsc",
"lint": "eslint ./src/ --fix"
},
"type": "module",
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.18",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"eslint": "^8.50.0",
"typescript": "^5.2.2",
"tsx": "^3.14.0"
}
}De development server kan nu gestart worden met onderstaand commando.
Hello World
Een server starten is bijzonder eenvoudig, via de express functie creëren we een server. Vervolgens starten we de server via de listen methode waaraan we de poort meegeven waarop de server draait en een callback functie die de URL van de server uitprint naar de console (zodat we er op kunnen klikken om de browser te openen).
import express, {Request, Response} from 'express'
const app = express()
const port = 3000
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})Route Definiëren
Als we de server proberen te openen via een website, krijgen we een foutmelding, zoals te zien in onderstaand screenshot.

Om dit probleem op te lossen moeten we luisteren naar requests op de / route en iets teruggeven als zo'n request gedetecteerd wordt.
Begrip: Express Route Handler
Een Express route handler kan aangemaakt worden via een instantie van de Router klasse. Deze klasse bevat methodes voor elke HTTP-methode (get, post, put, delete, options en head). Elk van deze methodes heeft twee argumenten:
- De route waarvoor de handler gedefinieerd is.
- Een functie die de route afhandelt, deze functie heeft twee argumenten. Het eerste argument is het Request object, i.e. alle data die client naar de server gestuurd heeft. Het tweede argument is het Response object, i.e. alles wat de server terugstuurt naar de client.
Tenslotte moet de router instantie meegegeven worden aan de Express applicatie via de use methode. Deze neemt twee argumenten, het pad vanwaar de routes is de router beschikbaar zijn en de router instantie. Als we aan de eerste parameter de waarde '/some/sub/path' meegeven, dan zijn alle routes beschikbaar onder deze sub-url.
import express, { Request, Response} from 'express'
const app = express()
const port = 3000
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})
const router = express.Router()
router.get('/someRoute', (req: Request, res: Response) => {
res.send('Some Data')
})
app.use('/', router)Aangezien we het Request object hieronder niet nodig hebben, gebruiken we de discard parameter (_) om aan te geven aan de IDE dat deze parameter er enkel staat om te voldoen aan de signatuur van de methode, maar dat we die verder nergens gebruiken.
import express, { Request, Response} from 'express'
const app = express()
const port = 3000
const router = express.Router()
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})
router.get('/', (_: Request, res: Response) => {
res.send('<h1>Hello World</h1>')
})
app.use('/', router)Mappenstructuur aanpassen
Momenteel gebruiken één file waarin zowel de applicaties als de routes gedefinieerd zijn, dit is echter niet onderhoudbaar in grotere applicaties. Daar zullen we in het vervolg van deze cursus de routes steeds in de map /src/routes definiëren. In deze map plaatsen we een submap per endpoint, waarin we dan weer een bestand plaatsen met de routes. Als er geneste routes aanwezig zijn voegen we in de submap een extra sub-submap toe.
We verhuizen de '/' route dus naar de routes map, omdat dit de root route is moet er geen submap aangemaakt worden. De HTML-inhoud hieronder geeft aan welke routes er beschikbaar zijn in de API, dit is puur informatief en wordt verder niet gebruikt.
De route die in index.ts gedefinieerd is wordt geëxporteerd en gebruikt in app.ts.
import express, {Request, Response} from 'express'
index.get('/', (_: Request, res: Response) => {
res.send(`
<h1>Welcome to the lecture 1 example API, these are the available routes</h1>
<h2><a href="/">/</a></h2>
<p>Where you are right now</p>
<hr/>
<h2><a href="/campuses">/campuses</a></h2>
<ul>
<li>
GET: Return all the campuses in the database.
</li>
<li>
POST: Create a new campus. Uses <i>req.body</i>, which means an object must be passed in the request.
</li>
</ul>
<hr/>
<h2>/campuses/:id</h2>
<ul>
<li>
GET: Retrieve a specific campus,
<a href="/campuses/c85bb0bd-ea87-43f8-b746-da32e4afbd0a">/campuses/c85bb0bd-ea87-43f8-b746-da32e4afbd0a</a>
retrieves the campus with ID c85bb0bd-ea87-43f8-b746-da32e4afbd0a.
</li>
<li>
PUT: Update a campus by passing one or more fields in the body of the request.
</li>
</ul>
`)
})
export default indeximport express from 'express'
import index from './routes/index.js'
const app = express()
const port = 3000
app.use(index)
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})Campus API
Tijdens deze les bouwen we een API uit waarmee de verschillende campussen van Thomas More uitgelezen en bewerkt kunnen worden. Hiervoor gebruiken we voorlopig een in-memory database, wat zoveel betekent als; We laden een array met hardcoded data in en voegen hier vervolgens CRUD-operaties op uit. De database operaties zijn reeds te vinden in de startbestanden.
Om deze API calls te testen kan gebruik gemaakt worden van Postman. Deze software is, voor de functionaliteiten die wij nodig hebben, relatief gemakkelijk in gebruik. Voor de studenten die graag een tutorial bekijken, verwijzen we door naar de Postman documentatie.
Alle campussen ophalen
We beginnen met een route te bouwen die alle campussen teruggeeft. Zoals eerder gezegd, creëren we een submap voor de routes die via /campuses bereikbaar zijn. Binnen deze map instantiëren we een nieuwe Router instantie waarin de routes voor het /campuses endpoint gedefinieerd worden. Deze instantie wordt dan geëxporteerd en vervolgens gebruik in de bovenliggende route (/). Omdat de index-route geïmporteerd wordt in app.ts, worden de /campuses routes beschikbaar voor de Express applicatie.
Merk op dat we via de generische parameter in het response object aangeven wat voor soort data er teruggestuurd moet worden.
import {getCampuses} from '../../dal/campuses.js'
import ICampus from '../../models/ICampus.js'
const campuses = express.Router()
campuses.get('/campuses', (_: Request, res: Response<ICampus[]>) => {
res.send(getCampuses())
})
export default campusesimport express, {Request, Response} from 'express'
import campuses from './campuses/campuses.js'
const index = express.Router()
index.get('/', (_: Request, res: Response) => {
res.send(`
...
`)
})
index.use(campuses)
export default indexAls we de route bezoeken in een browser zien we duidelijk dat we een lijst van campussen terugkrijgen. Firefox biedt standaard ondersteuning om JSON-code proper weer te geven, als je gebruik maakt van een Chromium gebaseerde browser kan je de JSON-formatter plug-in installeren.

Specifieke campus ophalen
Om een specifieke campus op te halen moeten het ID van de campus kunnen aflezen uit de URL. We gaan dus een URL van de vorm /campuses/c85bb0bd-ea87-43f8-b746-da32e4afbd0a nodig hebben. Om een parameter toe te voegen in de URL kunnen we een dubbelpunt gebruiken in de definitie van de route, achter het dubbelpunt komt dan de naam van de parameter.
We spreken af dat we de navigatie parameter nooit id zullen noemen. Dit werkt zolang er geen geneste routes zijn, maar stel dat we voor een bepaalde campus informatie willen ophalen over een bepaald lokaal. De route is in dit geval iets als /campuses/c85bb0bd-ea87-43f8-b746-da32e4afbd0a/rooms/cb6a1fbc-ec31-4155-bc81-a1991cafde38. Als we beide parameters nodig hebben is duidelijk niet mogelijk om deze allebei de naam id te geven. Om zulke problemen te vermijden gebruiken we dezelfde naamgeving als voor foreign keys, in bovenstaand hypothetisch voorbeeld zou het dus campusId en roomId zijn.
De routes die gebruik maken van een parameter zijn technisch gezien geneste routes en worden daarom dan ook in een nieuwe map geplaatst. Om aan te geven dat de route een parameter bevat gebruiken we vierkante haken in de naam.
Dit alles leidt ons dus tot onderstaande code. Merk op dat we een interface definiëren die de parameters beschrijft en dat deze interface meegegeven wordt als generische parameter aan de Request interface. Verder geven we de status code 400 terug wanneer de campus niet gevonden kan worden. Dit geeft aan de client aan dat het request slecht opgebouwd is en niet herhaald mag worden zonder aanpassingen.
Het is tenslotte weer nodig om de nieuwe route te importeren in een andere file. We gebruiken hier opnieuw de bovenliggende route voor. Merk op dat het nodig is om aan de router mee te geven over welk sub-pad het gaat (lijn 12), doen we dit niet, dan zullen de routes kinderen worden van de index route (/).
import express, {Request, Response} from 'express'
import {deleteCampus, getCampus, updateCampus} from '../../../dal/campuses.js'
import ICampus from '../../models/ICampus.js'
const campus = express.Router()
interface RouteParams {
campusId: string
}
campus.get('/:campusId', (req: Request<RouteParams>, res: Response<ICampus>) => {
const campus = getCampus(req.params.campusId)
if (campus) {
res.send(campus)
} else {
// Bad Request
res.sendStatus(400)
}
})
export default campusimport express, {Request, Response} from 'express'
import {addCampus, getCampuses} from '../../dal/campuses.js'
import ICampus from '../../models/ICampus.js'
import campus from './[campusId]/campus.js'
const campuses = express.Router()
campuses.get('/campuses', (_: Request, res: Response<ICampus[]>) => {
res.send(getCampuses())
})
campuses.use('/campuses', campus)
export default campusesAls we het pad /campuses/c85bb0bd-ea87-43f8-b746-da32e4afbd0a bezoeken krijgen we, zoals in onderstaand screenshot te zien is, de informatie over de campus in Geel te zien.

Campus verwijderen
De code om een campus te verwijderen is zeer gelijkaardig aan de code om een specifieke campus op te halen, er is opnieuw een parameter nodig, we plaatsen deze route dus in hetzelfde bestand als de vorige route.
import express, {Request, Response} from 'express'
import {deleteCampus, getCampus, updateCampus} from '../../../dal/campuses.js'
const campus = express.Router()
interface RouteParams {
campusId: string
}
campus.get('/:campusId', (req: Request<RouteParams>, res: Response<ICampus>) => {
// Weggelaten in dit fragment.
})
campus.delete('/:campusId', (req: Request<RouteParams>, res: Response) => {
deleteCampus(req.params.campusId)
res.sendStatus(200)
})
export default campusCampus aanmaken
Om een campus aan te maken is geen parameter vereist in de URL, daarom wordt deze code dus in campuses.ts geplaatst. We hebben natuurlijk wel input van de gebruiker nodig, de gebruiker moet de naam, locatie, ... doorgeven aan de API. In de plaats van een URL-parameter gebruiken we de body van het request. Deze data kan als FormData of JSON verstuurd worden, omdat we in de rest van de API gebruik maken van JSOn zullen we dit hier ook doen.
Express kan JSON niet niet zomaar decoderen, we moeten deze functionaliteit activeren via de JSON middleware.
Begrip: Middleware
Middleware functies zijn functies die toegang hebben tot het request en response object. Een middleware functie kan deze objecten gebruiken om:
- Het request of response object te bewerken
- Het request te beëindigen (bijvoorbeeld als de gebruiker niet geauthenticeerd is)
- Een volgende middleware functie oproepen
Middleware kan gekoppeld worden aan de volledige applicatie of aan een specifieke route.
import express, {NextFunction, Request, Response} from 'express'
const app = express()
const port = 3000
function middleware(req: Request, res: Response, next: NextFunction) {
// Do iets met het request en/of response object.
// Roep de volgende middleware functie op of gebruik de route handler als
// er geen volgende middleware functie is.
next()
}
// Gebruik de middleware voor de volledige app.
app.use(middleware)
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})De JSON middleware functie kan gebruikt worden om de data die de gebruiker verstuurd correct te verwerken en uit te lezen als een JavaScript object. Nadat deze middleware functie geladen is, kunnen we de payload van het request uitlezen via request.body.
De Request interface heeft verschillende generische parameters. Via de eerste parameter kan, zoals eerder besproken, aangegeven worden welke parameters de route bevat. Via de tweede en derde parameter kan respectievelijk aangegeven worden welke data er in de payload van het response- en request-object zitten. De IdOptional interface die hieronder gebruikt wordt is voorzien in de startbestanden.
Merk op dat we in onderstaand voorbeeld de status code 400 (bad request) teruggeven als er ongeldige data verstuurd wordt en dat we 201 (created) versturen als de nieuwe campus succesvol aangemaakt is. De route geeft ook de nieuwe campus (inclusief id) terug.
import express from 'express'
import index from './routes/index.js'
const app = express()
const port = 3000
// Allow the application to parse incoming JSON data in POST or PUT requests.
app.use(express.json())
app.use(index)
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})const campuses = express.Router()
campuses.post('/campuses', (req: Request<void, ICampus, IdOptional<ICampus>>, res: Response<ICampus>) => {
const {name, location, address, image} = req.body
if (!name || name.length === 0 || !location || location.length === 0 || !address || address.length === 0 || !image || image.length === 0) {
res.statusCode = 400
res.send('Invalid data, please modify the request and try again.')
}
const newCampus = addCampus({name, location, address, image} as ICampus)
res.statusCode = 201
res.send(newCampus)
})
campuses.use('/campuses', campus)
export default campusesCampus updaten
Om een campus aan te passen gebruiken we een heel gelijkaardige werkwijze als voor het aanmaken van een campus. Ook hier moeten we de request payload uitlezen via req.body. Vervolgens kan deze informatie gebruikt worden om een bestaande campus aan te passen.
const campuses = express.Router()
campus.put('/:campusId', (req: Request<RouteParams, ICampus, IdOptional<ICampus>>, res: Response<ICampus>) => {
const campus = updateCampus(req.params.campusId, req.body)
if (!campus) {
res.sendStatus(400)
} else {
res.send(campus)
}
})
campuses.use('/campuses', campus)
export default campusesCORS
Zoals hierboven getoond werkt de API nu in Postman. Als we de API proberen te gebruiken vanuit de React applicatie, die in de startbestanden beschikbaar is, stoten we echter op een probleem.
We krijgen onderstaande foutmelding te zien in de developer console in de browser:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:3000/campuses. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200.
Zoals de foutmelding aangeeft, wordt deze fout veroorzaakt door CORS.
Begrip: CORS
CORS (Cross-Origin Resource Sharing) is een systeem waarbij niet toegestane, vanuit JavaScript verstuurde, HTTP request geblokkeerd kunnen worden via HTTP Headers.
Standaard worden alle requests naar een andere origin geblokkeerd. Omdat elke website client-side uitgevoerd wordt, worden dus standaard alle requests vanuit de browser geblokkeerd. Op deze manier wordt het veel moeilijker voor kwaadwilligen om JavaScript code uit te voeren via een extensie, bookmark, of andere attack vector.
Natuurlijk werkt dit enkel voor HTTP requests en niet voor andere JavaScript code, maar HTTP requests zijn altijd nodig om gegevens te stelen die niet rechtstreeks op de webpagina verschijnen.
Natuurlijk zijn er API's die zonder problemen publiek beschikbaar gesteld kunnen worden. Voor deze API's moeten de Access-Control-Allow-Origin header ingesteld worden voor elk GET en POST endpoint. Hier kunnen meerdere origins aan meegegeven worden, de origin *** kan gebruikt worden om de route overal beschikbaar te maken.
GET & POST requests
We kunnen dit probleem heel eenvoudig oplossen door de Access-Control-Allow-Origin header toe te voegen aan de requests die momenteel mislukken. Omdat elk request standaard mislukt schrijven we een eigen middelware functie die deze header toevoegt aan elk request.
De middleware functie heeft de twee argumenten die we ook gebruiken in een route handler, daarnaast is er een derde parameter aanwezig. Deze laatste parameter is een functie waarmee we de volgende middleware functie of route handler kunnen oproepen.
Natuurlijk moet deze middleware functie ook gekoppeld worden aan de applicatie, de volgorde waarin middleware en route handlers gekoppeld worden is belangrijk.
Op dit moment steunt geen enkele van de middleware functies op een andere, natuurlijk steunen de route handlers wel op de middleware functies en moeten deze als laatste gekoppeld worden.
import {NextFunction, Request, Response} from 'express'
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
res.header({
'Access-Control-Allow-Origin': '*',
})
next()
}import express from 'express'
import index from './routes/index.js'
import {corsMiddleware} from './middleware/cors.js'
const app = express()
const port = 3000
app.use(corsMiddleware)
app.use(express.json())
app.use(index)
app.listen(port, () => {
console.log(`Express is listening at http://localhost:${port}`)
})DELETE & PUT
De campussen worden nu wel correct weergegeven in de React applicatie, maar het is nog steeds niet mogelijk om een campus te verwijderen. De developer console toont nu een andere error.
Het probleem wordt veroorzaakt omdat de API nog geen ondersteuning biedt voor preflight requests.
Begrip: CORS Preflight Requests
Als een HTTP Request een side-effect heeft, i.e. als er data aangepast wordt op de server (PUT, DELETE, POST (in sommige gevallen)), wordt een preflight request uitgevoerd.
De browser voert een preflight request uit voordat het echte request verstuurd wordt. Voor dit preflight request wordt de HTTP OPTIONS methode gebruikt. Afhankelijk van de het antwoord dat de browser krijgt op het preflight request, wordt het PUT of DELETE request al dan niet uitgevoerd.
Een preflight request moet twee headers teruggeven, de eerste header Access-Control-Allow-Origin wordt ook gebruikt voor GET en POST requests. De tweede header is Access-Control-Allow-Methods, hiermee wordt aangegeven welke HTTP-methodes toegestaan zijn voor een bepaald endpoint.
Naast deze twee verplichte headers zijn er ook verschillende optionele:
- Access-Control-Allow-Headers: De headers die toegestaan zijn in het PUT, DELETE of POST request.
- Access-Control-Allow-Credentials: Of credentials ( cookies of de Authorization header) toegestaan zijn.
- Access-Control-Expose-Headers: De headers die door de client uitgelezen mogen worden.
- Access-Control-Max-Age: Geeft aan hoeveel seconden het resultaat van een preflight request bewaard mag worden door de browser.
We moeten dus reageren op een OPTION request, hiervoor gebruiken we opnieuw de CORS-middleware functie.
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
// This header is required to allow requests from a web-application.
// The header will be appended to every HTTP response for every method.
// If the header isn't present, all requests from a browser will be blocked.
res.header({
'Access-Control-Allow-Origin': '*',
})
if (req.method === 'OPTIONS') {
res.header({
'Access-Control-Allow-Headers': 'Authorization',
'Access-Control-Allow-Methods': 'POST, PUT, GET, DELETE',
'Access-Control-Max-Age': '60',
})
}
next()
}Voorbeeldcode
Startbestanden
Bronnen: https://www.ibm.com/topics/rest-apis, https://blog.postman.com/rest-api-examples/, https://aws.amazon.com/what-is/restful-api/ ↩︎
Loose coupling is een principe van goed software ontwerpt. Twee klassen (of in dit geval de server en de client) moeten zo weinig mogelijk van elkaar weten. Eén van de twee klassen (in dit geval de client) kan de andere (in dit geval de server) aanpreken en gebruiken, maar dit mag absoluut geen wederzijdse connectie zijn. Het aanpreken van de andere klasse mag ook niet steunen op kennis van de interne werking van de klasse die aangesproken wordt, maar mag enkel afgaan op de publieke API van de aangesproken klassen. Voor meer informatie verwijzen we door naar de Wikipedia pagina voor het derde GRASP principe. ↩︎