Byg et lille Express API der ikke vælter ved første request
Over 80% af alle “første backendprojekter” jeg ser, har nul inputvalidering og én stor try/catch der bare skriver console.log(err). Resten håber man på.
Hvis du kan nikke bare en lille smule til det, er den her tur gennem Express til dig.
Hvad vi bygger, og hvad vi kræver af det
Vi bygger et helt lille REST API i Node.js med Express.
Ikke noget fancy. Én ressource: todos.
API’et får fire endpoints:
GET /todos– hent allePOST /todos– opret en nyPUT /todos/:id– opdatér en todoDELETE /todos/:id– slet en todo
Til gengæld stiller vi nogle ret stramme krav til, hvordan det opfører sig:
- Alt input bliver valideret, ikke bare “det ser rimeligt ud”.
- Alle fejl kommer tilbage i et stabilt JSON-format.
- Vi bruger HTTP-statuskoder meningsfuldt (ikke kun 200 og 500).
- Vi har automatiske tests på de vigtigste flows.
Det her er sådan et minimumsniveau, jeg selv bruger til små hobbyprojekter. Det er også et godt første skridt ind i backend til web, uden at du drukner i framework-magi.
Start opsætning og projektstruktur uden at overgøre det
Initier projektet og installér Express
Lav en tom mappe og kør:
npm init -y
npm install express
npm install --save-dev nodemon jest supertest
Vi bruger:
- express til selve HTTP-delen
- nodemon til auto-restart lokalt
- jest til tests
- supertest til at teste vores API-endpoints
I package.json opdaterer du scripts:
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"test": "jest"
}
Mappen vi starter med
Vi går efter en simpel struktur, der stadig holder til at vokse lidt:
src/
server.js // starter Express og lytter på en port
app.js // selve Express appen, uden at lytte
routes/
todoRoutes.js // Express router for /todos
controllers/
todoController.js
services/
todoService.js // "forretning": håndtering af todos
middleware/
errorHandler.js
validateRequest.js
utils/
ApiError.js
Det ser ud af meget, men alle filer er små. Det vigtige her er at skille tingene ad:
- server.js starter kun serveren.
- app.js har routes og middleware.
- controllers ved noget om HTTP (req, res).
- services ved noget om data og regler.
Det mønster går igen i mange projektstrukturer, og det er nemt at bære med sig videre.
Første version af server og app
src/server.js:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server lytter på port ${PORT}`);
});
src/app.js:
const express = require('express');
const todoRoutes = require('./routes/todoRoutes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(express.json());
app.use('/todos', todoRoutes);
app.use(errorHandler);
module.exports = app;
Læg mærke til rækkefølgen:
- Vi registrerer routes først.
- Til sidst tilføjer vi error middleware.
Det er en af de der detaljer, der bider én senere, hvis man får det vendt forkert.
Todo-logikken: service og controller
En simpel in-memory service
Vi starter uden database. Bare et array i hukommelsen, så vi kan fokusere på API’et. Senere kan du bytte det ud med en rigtig database fra kategorien data og databaser.
src/services/todoService.js:
let todos = [];
let nextId = 1;
function listTodos() {
return todos;
}
function createTodo({ title, completed = false }) {
const todo = {
id: nextId++,
title,
completed: Boolean(completed)
};
todos.push(todo);
return todo;
}
function updateTodo(id, { title, completed }) {
const index = todos.findIndex(t => t.id === id);
if (index === -1) {
return null;
}
const existing = todos[index];
const updated = {
...existing,
title: title !== undefined ? title : existing.title,
completed: completed !== undefined
? Boolean(completed)
: existing.completed
};
todos[index] = updated;
return updated;
}
function deleteTodo(id) {
const index = todos.findIndex(t => t.id === id);
if (index === -1) {
return false;
}
todos.splice(index, 1);
return true;
}
module.exports = {
listTodos,
createTodo,
updateTodo,
deleteTodo
};
Bemærk: Servicen kender ikke til HTTP, kun data og simple regler.
Controlleren: broen mellem HTTP og service
src/controllers/todoController.js:
const todoService = require('../services/todoService');
async function getTodos(req, res, next) {
try {
const todos = todoService.listTodos();
res.json(todos);
} catch (err) {
next(err);
}
}
async function createTodo(req, res, next) {
try {
const todo = todoService.createTodo(req.body);
res.status(201).json(todo);
} catch (err) {
next(err);
}
}
async function updateTodo(req, res, next) {
try {
const id = Number(req.params.id);
const updated = todoService.updateTodo(id, req.body);
if (!updated) {
return res.status(404).json({
error: 'NotFound',
message: 'Todo ikke fundet',
status: 404
});
}
res.json(updated);
} catch (err) {
next(err);
}
}
async function deleteTodo(req, res, next) {
try {
const id = Number(req.params.id);
const deleted = todoService.deleteTodo(id);
if (!deleted) {
return res.status(404).json({
error: 'NotFound',
message: 'Todo ikke fundet',
status: 404
});
}
res.status(204).send();
} catch (err) {
next(err);
}
}
module.exports = {
getTodos,
createTodo,
updateTodo,
deleteTodo
};
Ja, alle handler-funktioner har try/catch og next(err). Det ser lidt kedeligt ud, men det giver os én central vej for fejl senere.
Routes: bind URL’er til controllers
src/routes/todoRoutes.js:
const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');
router.get('/', todoController.getTodos);
router.post('/', todoController.createTodo);
router.put('/:id', todoController.updateTodo);
router.delete('/:id', todoController.deleteTodo);
module.exports = router;
På det her tidspunkt har du et fungerende API, men det accepterer alt muligt skrald som input, og fejlformaterne er ikke ens. Det fikser vi nu.
Validering af input uden kæmpe frameworks
Hvor skal vi validere hvad?
Et API får input flere steder:
- Body (POST/PUT) – JSON-objekter med data.
- Params (fx
/todos/:id) – typisk id’er. - Query (
?page=2&limit=10) – filtre og pagination.
Tommelregel jeg selv bruger:
- Valider så tæt på kanten som muligt, altså i middleware.
- Controlleren skal kun modtage “ren” og allerede tjekket data.
En lille ApiError helper
Vi vil gerne kunne kaste fejl med en statuskode og en maskinlæsbar type.
src/utils/ApiError.js:
class ApiError extends Error {
constructor(status, error, message, details) {
super(message);
this.status = status;
this.error = error;
this.details = details;
}
}
module.exports = ApiError;
Middleware til request-validering
Vi kan skrive en generisk validator, hvor vi selv definerer regler pr. route. Vi holder det simpelt med manuelle checks.
src/middleware/validateRequest.js:
const ApiError = require('../utils/ApiError');
function validate(schema) {
return (req, res, next) => {
const errors = [];
if (schema.params) {
const result = schema.params(req.params);
errors.push(...result);
}
if (schema.body) {
const result = schema.body(req.body);
errors.push(...result);
}
if (schema.query) {
const result = schema.query(req.query);
errors.push(...result);
}
if (errors.length > 0) {
return next(new ApiError(
400,
'BadRequest',
'Request indeholder ugyldige felter',
errors
));
}
next();
};
}
module.exports = validate;
Her forventer vi, at schema.body osv. er funktioner, der returnerer en liste af fejl, eller en tom liste.
En simpel validering for todos
src/routes/todoRoutes.js (opdateret):
const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');
const validate = require('../middleware/validateRequest');
function validateIdParams(params) {
const errors = [];
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) {
errors.push({
field: 'id',
message: 'id skal være et positivt helt tal'
});
}
return errors;
}
function validateCreateBody(body) {
const errors = [];
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
errors.push({
field: 'title',
message: 'title er påkrævet og skal være en ikke-tom tekst'
});
}
if (body.completed !== undefined && typeof body.completed !== 'boolean') {
errors.push({
field: 'completed',
message: 'completed skal være en boolean hvis den er sat'
});
}
return errors;
}
function validateUpdateBody(body) {
const errors = [];
if (body.title !== undefined &&
(typeof body.title !== 'string' || body.title.trim().length === 0)) {
errors.push({
field: 'title',
message: 'title skal være en ikke-tom tekst hvis den er sat'
});
}
if (body.completed !== undefined && typeof body.completed !== 'boolean') {
errors.push({
field: 'completed',
message: 'completed skal være en boolean hvis den er sat'
});
}
return errors;
}
router.get('/', todoController.getTodos);
router.post(
'/',
validate({ body: validateCreateBody }),
todoController.createTodo
);
router.put(
'/:id',
validate({ params: validateIdParams, body: validateUpdateBody }),
todoController.updateTodo
);
router.delete(
'/:id',
validate({ params: validateIdParams }),
todoController.deleteTodo
);
module.exports = router;
Nu kommer der en pæn 400-fejl tilbage, hvis nogen sender noget mystisk i body eller path.
Fejlhåndtering med fast JSON-format
Én error middleware til alle fejl
Express har en særlig type middleware med 4 argumenter. Den bliver kun kaldt, hvis du kalder next(err).
src/middleware/errorHandler.js:
const ApiError = require('../utils/ApiError');
function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err);
}
if (err instanceof ApiError) {
return res.status(err.status).json({
error: err.error,
message: err.message,
status: err.status,
details: err.details || null
});
}
console.error('Uventet fejl', err);
res.status(500).json({
error: 'InternalServerError',
message: 'Noget gik galt på serveren',
status: 500
});
}
module.exports = errorHandler;
Nu har vi:
- Ens format for alle kendte fejl vi selv kaster.
- En standard 500-fejl for ting, vi ikke havde forudset.
4xx vs 5xx i praksis
Hurtig huskeregel:
- 4xx: Klientens fejl (dårlig input, manglende auth, ikke fundet).
- 5xx: Serverens fejl (bug i koden, database nede).
Vi bruger:
- 400 Bad Request til valideringsfejl.
- 404 Not Found når
idikke findes. - 500 Internal Server Error til alt andet, som vi ikke har mappet.
Det lyder lidt kedeligt, men de her små ting betyder meget, når du senere bygger frontend ovenpå. Især når du har lært at lave REST-kald uden at være nervøs.
Tests: lige nok til at opdage når du ødelægger noget
Testsetup med Jest og Supertest
Vi har allerede installeret jest og supertest. Nu laver vi en testfil.
Lav en mappe tests i roden:
tests/
todoApi.test.js
tests/todoApi.test.js:
const request = require('supertest');
const app = require('../src/app');
describe('Todo API', () => {
it('opretter og henter en todo', async () => {
const createRes = await request(app)
.post('/todos')
.send({ title: 'Lær Express' })
.expect(201);
expect(createRes.body).toHaveProperty('id');
expect(createRes.body.title).toBe('Lær Express');
expect(createRes.body.completed).toBe(false);
const listRes = await request(app)
.get('/todos')
.expect(200);
expect(listRes.body.length).toBeGreaterThanOrEqual(1);
});
it('validerer input ved oprettelse', async () => {
const res = await request(app)
.post('/todos')
.send({ title: '' })
.expect(400);
expect(res.body).toMatchObject({
error: 'BadRequest',
status: 400
});
expect(Array.isArray(res.body.details)).toBe(true);
});
it('returnerer 404 for ukendt id ved update', async () => {
const res = await request(app)
.put('/todos/9999')
.send({ title: 'Ny titel' })
.expect(404);
expect(res.body.error).toBe('NotFound');
});
});
Kør:
npm test
Hvis du pludselig ændrer errorformatet eller fjerner valideringen ved en fejl, vil de her tests brokke sig. Det er præcis pointen.
Lokal udvikling uden rod i miljøvariabler og scripts
.env og .gitignore
Vi har ikke brug for hemmeligheder endnu, men det er en god vane at få ind fra start.
Installer dotenv:
npm install dotenv
Opdater src/server.js:
require('dotenv').config();
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server lytter på port ${PORT}`);
});
Lav en .env i roden:
PORT=3000
Og en .env.example:
PORT=3000
Til sidst: tilføj .env til .gitignore:
node_modules
.env
Hvis du vil nørde videre i miljøvariabler, har jeg skrevet mere om det under tagget miljøvariabler.
Seed data i udvikling
Med in-memory storage forsvinder data, når du genstarter serveren. Det er faktisk meget rart under udvikling, men du kan også lave en lille seed-funktion i todoService, hvis du vil starte med nogle standardtodos.
En nem løsning er bare at eksportere en _resetForTests-funktion fra servicen, og kalde den fra dine tests. Det holder state nogenlunde under kontrol.
Typiske faldgruber i små Express API’er
Async errors der ikke rammer error middleware
En klassiker er at glemme try/catch i async handlers. Express forstår ikke async/await fejl af sig selv, de ender som unhandled rejections, hvis du ikke sender dem videre til next.
Derfor bruger vi mønstret:
async function handler(req, res, next) {
try {
// ...
} catch (err) {
next(err);
}
}
Der findes helpers og små libs til at wrappe det, men som ny er det fint at skrive det selv, så du ser, hvad der foregår.
“Catch alt” og kun 500-fejl
En anden klassiker: Én stor try/catch omkring hele request-flowet, hvor alt bliver til 500.
Problemet er, at du gør det svært for dig selv at skelne mellem:
- Brugeren sendte noget forkert.
- Du har en bug i din kode.
Det er derfor vi har ApiError og valideringsmiddleware: Kendte, forventede fejl får 4xx. Alt andet bliver 5xx.
Race conditions i in-memory data
Med bare ét Node-process og få requests er du ret sikker. Men hvis du senere smider det her bag en load balancer og kører flere instanser, vil in-memory data være forskellig på hver instans.
Det er en af grundene til, at man ret hurtigt skal videre til en database eller et andet centraliseret lager, hvis projektet vokser.
Næste skridt: database, auth og deploy
Tilføj en database
Når du er tryg med den her lille in-memory version, er næste naturlige skridt at erstatte todoService med noget, der taler med en database.
Du kan f.eks.:
- Bruge SQLite eller Postgres og et query-bibliotek.
- Beholde samme controller og routes, kun ændre servicen.
Det er en ret konkret øvelse i at holde lagene adskilt, og det hjælper også, når du senere bygger full stack workflows med både frontend og backend.
Auth og sessions
Når du vil styre, hvem der må ændre hvilke todos, skal du have:
- En bruger-model.
- Login og token (fx JWT).
- Middleware der læser token og sætter
req.user.
Her begynder tags som JWT og sessions at give mening at udforske.
Deploy og drift
Til sidst skal din lille Express-app jo også ud af din egen maskine.
Det kan være en lille VPS, en PaaS-løsning eller serverless. Uanset hvad du vælger, vil det hjælpe, at du allerede har:
- Miljøvariabler via .env.
- Et start-script (
npm start). - Tests, der kan køre i CI.
Når du kommer dertil, er kategorien deployment og drift et fint næste kaninhul at hoppe ned i.
En lille opsummering før du skriver den første route igen
Hvis du vil have en mental tjekliste, før du kalder din næste Express-app “færdig”, kan du bruge den her:
- Har hver route validering af body/params, der faktisk bliver brugt?
- Har du ét error middleware, der giver stabilt JSON-format?
- Bruger du 400/404/500 på en nogenlunde gennemskuelig måde?
- Har du mindst 2-3 tests, som fanger, hvis du knækker API-kontrakten?
Hvis du kan sige ja til det, er du allerede foran mange “det virker på min maskine”-backends.
Og hvis du ligesom mig har haft en Node-app, der crashede på grund af et tomt JSON-felt kl. 23 en tirsdag, så ved du, hvorfor det føles lidt som selvomsorg at skrive valideringen før resten af features.









Send kommentar
Du skal være logget ind for at skrive en kommentar.