Byg et lille Express API der ikke vælter ved første request

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 alle
  • POST /todos – opret en ny
  • PUT /todos/:id – opdatér en todo
  • DELETE /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 id ikke 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.

Til hurtigt prototyping eller øvelsesprojekter er in-memory eller en lille filbaseret DB (fx lowdb eller SQLite) fint. Til alt der skal være vedholdende eller skalerbart, brug en rigtig database (Postgres, MongoDB mm.) og abstrakt adgang via din service-lag, så du kan skifte storage uden at røre controllers eller routes.
Vælg ud fra behov: Zod giver typesikker runtime-validering og passer godt hvis du også bruger TypeScript, Joi er moden og fleksibel, og express-validator integrerer request-level validators uden ekstra wrappers. Prioriter tydelige fejlbeskeder og enkel mapping til dit validateRequest-middleware.
Brug en async-wrapper som asyncHandler(fn) der fanger fejl og forwarder til next(err), eller installer pakken express-async-errors som automatisk håndterer async-throws. Husk også at lytte på process events som unhandledRejection og uncaughtException for at logge og lukke pænt ned ved alvorlige fejl.
Brug Supertest til integrationstests mod app.js (ikke den lytende server) og mock dine services i unit-tests for hurtig feedback. Skriv tests for happy path, valideringsfejl, relevante statuskoder (400, 404, 500) og brug before/after hooks til at nulstille testdata eller spin up en testdatabase.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

Send kommentar

You May Have Missed