REST API bouwen met Python en Flask (voor gevorderde beginners)
Dit is een complete, praktijkgerichte tutorial om een REST API te bouwen met Python en Flask. Je leert niet alleen wat je moet doen, maar vooral waarom je het zo doet: projectstructuur, configuratie, validatie, foutafhandeling, versies, authenticatie, testen, logging en deployment-achtige aandachtspunten. Alle voorbeelden zijn uitvoerbaar en bevatten echte commando’s.
Inhoud
- Doel en uitgangspunten
- Benodigdheden
- Project opzetten (virtuele omgeving, dependencies)
- Projectstructuur voor groei
- Eerste Flask-app en healthcheck
- REST-principes en HTTP-statuscodes
- Een voorbeeld-API: takenlijst
- In-memory opslag en later uitbreiden
- Routes bouwen: CRUD voor taken
- Validatie en consistente fouten (JSON)
- Paginering, filtering en sortering
- Versiebeheer van je API
- Authenticatie met API-sleutels (eenvoudig maar nuttig)
- Logging en observatie
- Testen met pytest
- CORS en clients
- Productie-achtige run met gunicorn
- Volgende stappen
Doel en uitgangspunten
Je gaat een REST API bouwen die “taken” beheert. De API ondersteunt:
GET /api/v1/tasks(lijst, met paginering en filtering)POST /api/v1/tasks(nieuwe taak)GET /api/v1/tasks/<id>(details)PATCH /api/v1/tasks/<id>(gedeeltelijke update)DELETE /api/v1/tasks/<id>(verwijderen)
Belangrijke ontwerpkeuzes:
- JSON als request/response-formaat
- Consistente foutresponses met duidelijke codes en berichten
- Scheiding van concerns: routes, services, opslag, configuratie
- Testbaarheid: app-factory patroon en pytest
- Eenvoudige authenticatie met een API-sleutel in een header
We gebruiken Flask omdat het lichtgewicht is en je veel controle geeft. Dit is ideaal om REST-concepten goed te begrijpen.
Benodigdheden
- Python 3.11 of nieuwer (3.10 kan ook, maar voorbeelden gaan uit van moderne features)
- Basiskennis van Python (functies, dicts, exceptions)
- Basiskennis van HTTP (GET/POST, headers, statuscodes)
- Een terminal
Controleer je Python-versie:
python --version
Project opzetten (virtuele omgeving, dependencies)
Maak een map en initialiseer een virtuele omgeving:
mkdir flask-rest-api-tutorial
cd flask-rest-api-tutorial
python -m venv .venv
Activeer de omgeving:
- macOS/Linux:
source .venv/bin/activate
- Windows (PowerShell):
.venv\Scripts\Activate.ps1
Installeer dependencies:
pip install Flask python-dotenv pytest gunicorn
Leg je dependencies vast:
pip freeze > requirements.txt
Waarom dit belangrijk is:
- Een virtuele omgeving voorkomt versieconflicten met andere projecten.
python-dotenvlaat je lokale omgevingsvariabelen laden via een.env-bestand.pytestgebruiken we voor tests.gunicornis een veelgebruikte WSGI-server om Flask “productie-achtig” te draaien.
Projectstructuur voor groei
Maak deze structuur:
mkdir -p app/api app/core app/services app/storage tests
touch app/__init__.py app/api/__init__.py app/core/__init__.py app/services/__init__.py app/storage/__init__.py
touch app/api/routes.py app/core/config.py app/core/errors.py app/services/tasks_service.py app/storage/memory.py
touch wsgi.py .env
Aanbevolen structuur:
app/__init__.py: app-factory, extensies, blueprint-registratieapp/api/routes.py: HTTP-routes (controllers)app/services/tasks_service.py: businesslogica (validatie, regels)app/storage/memory.py: opslaglaag (nu in-memory, later database)app/core/config.py: configuratieapp/core/errors.py: foutafhandeling en error responseswsgi.py: entrypoint voor gunicorntests/: pytest tests
Waarom deze structuur:
- Je voorkomt dat alles in één bestand eindigt.
- Je kunt later eenvoudig een database toevoegen zonder je routes volledig te herschrijven.
- Testen worden eenvoudiger omdat je lagen kunt isoleren.
Eerste Flask-app en healthcheck
Configuratie via .env
Vul .env:
FLASK_ENV=development
APP_NAME=TakenAPI
API_KEY=supergeheime-sleutel
Let op: zet echte secrets nooit in versiebeheer. In een echt project gebruik je een secrets manager of CI-variabelen.
app/core/config.py
import os
class Config:
APP_NAME = os.getenv("APP_NAME", "FlaskAPI")
API_KEY = os.getenv("API_KEY", "")
JSON_SORT_KEYS = False
JSON_SORT_KEYS = False zorgt dat Flask JSON sleutels niet automatisch sorteert; dit maakt responses vaak leesbaarder.
app/__init__.py (app-factory)
from flask import Flask
from dotenv import load_dotenv
from app.core.config import Config
from app.core.errors import register_error_handlers
from app.api.routes import api_bp
def create_app():
load_dotenv()
app = Flask(__name__)
app.config.from_object(Config)
app.register_blueprint(api_bp, url_prefix="/api/v1")
register_error_handlers(app)
@app.get("/health")
def health():
return {"status": "ok", "app": app.config["APP_NAME"]}, 200
return app
Waarom app-factory:
- Tests kunnen een app maken met andere configuratie.
- Je kunt meerdere instanties draaien met verschillende settings.
wsgi.py
from app import create_app
app = create_app()
Starten in development
Je kunt Flask direct draaien via een klein script, maar met deze structuur is het handig om Flask te wijzen op wsgi.py.
macOS/Linux:
export FLASK_APP=wsgi.py
flask run --port 5000
Windows (PowerShell):
$env:FLASK_APP="wsgi.py"
flask run --port 5000
Test de healthcheck:
curl -i http://127.0.0.1:5000/health
Je verwacht iets als:
- status
200 - JSON:
{"status":"ok","app":"TakenAPI"}
REST-principes en HTTP-statuscodes
REST is geen “magische library”, maar een set afspraken:
- Resources: je werkt met “dingen” (taken), niet met acties.
- URI’s:
/tasksvoor collectie,/tasks/<id>voor één item. - HTTP-methodes:
GETlezenPOSTmakenPATCHgedeeltelijk aanpassenPUTvolledig vervangen (gebruiken we hier niet, maar kan)DELETEverwijderen
- Statuscodes:
200 OK(succes)201 Created(nieuw object gemaakt)204 No Content(succes, geen body)400 Bad Request(ongeldige input)401 Unauthorized(niet ingelogd / geen sleutel)403 Forbidden(wel ingelogd, geen rechten)404 Not Found(bestaat niet)409 Conflict(conflict, bijvoorbeeld dubbele unieke waarde)422 Unprocessable Entity(soms gebruikt voor validatiefouten; we houden het bij 400)500 Internal Server Error(serverfout)
Een goede API is voorspelbaar: dezelfde foutstructuur, duidelijke codes, en zo min mogelijk “verrassingen”.
Een voorbeeld-API: takenlijst
Een taak heeft:
id(integer)title(verplicht, string)done(boolean, standaardFalse)created_at(ISO-achtige string)updated_at(ISO-achtige string)
We beginnen met in-memory opslag om de API te begrijpen. Daarna kun je eenvoudig overstappen op een database.
In-memory opslag en later uitbreiden
app/storage/memory.py
from __future__ import annotations
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Dict, List, Optional
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class Task:
id: int
title: str
done: bool
created_at: str
updated_at: str
class MemoryTaskRepository:
def __init__(self):
self._tasks: Dict[int, Task] = {}
self._next_id: int = 1
def list_tasks(self) -> List[dict]:
return [asdict(t) for t in self._tasks.values()]
def get_task(self, task_id: int) -> Optional[dict]:
task = self._tasks.get(task_id)
return asdict(task) if task else None
def create_task(self, title: str) -> dict:
task_id = self._next_id
self._next_id += 1
ts = now_iso()
task = Task(
id=task_id,
title=title,
done=False,
created_at=ts,
updated_at=ts,
)
self._tasks[task_id] = task
return asdict(task)
def update_task(self, task_id: int, *, title: Optional[str] = None, done: Optional[bool] = None) -> Optional[dict]:
task = self._tasks.get(task_id)
if not task:
return None
if title is not None:
task.title = title
if done is not None:
task.done = done
task.updated_at = now_iso()
return asdict(task)
def delete_task(self, task_id: int) -> bool:
return self._tasks.pop(task_id, None) is not None
Waarom repository:
- Je routes hoeven niet te weten hoe data wordt opgeslagen.
- Later vervang je
MemoryTaskRepositorydoor bijvoorbeeld een repository met een database, zonder je API-contract te breken.
Routes bouwen: CRUD voor taken
We gebruiken een Flask Blueprint voor versie v1.
app/api/routes.py
from flask import Blueprint, current_app, request
from app.core.errors import ApiError
from app.services.tasks_service import TasksService
from app.storage.memory import MemoryTaskRepository
api_bp = Blueprint("api", __name__)
_repo = MemoryTaskRepository()
_service = TasksService(_repo)
def require_api_key():
expected = current_app.config.get("API_KEY", "")
provided = request.headers.get("X-API-Key", "")
if not expected:
# Als er geen sleutel is geconfigureerd, staan we alles toe (handig lokaal).
return
if provided != expected:
raise ApiError(401, "unauthorized", "Ongeldige of ontbrekende API-sleutel")
@api_bp.get("/tasks")
def list_tasks():
require_api_key()
limit = request.args.get("limit", default="20")
offset = request.args.get("offset", default="0")
done = request.args.get("done") # optioneel: "true"/"false"
return _service.list_tasks(limit=limit, offset=offset, done=done), 200
@api_bp.post("/tasks")
def create_task():
require_api_key()
data = request.get_json(silent=True)
if data is None:
raise ApiError(400, "bad_request", "Request-body moet geldige JSON zijn")
task = _service.create_task(data)
return task, 201
@api_bp.get("/tasks/<int:task_id>")
def get_task(task_id: int):
require_api_key()
task = _service.get_task(task_id)
return task, 200
@api_bp.patch("/tasks/<int:task_id>")
def patch_task(task_id: int):
require_api_key()
data = request.get_json(silent=True)
if data is None:
raise ApiError(400, "bad_request", "Request-body moet geldige JSON zijn")
task = _service.patch_task(task_id, data)
return task, 200
@api_bp.delete("/tasks/<int:task_id>")
def delete_task(task_id: int):
require_api_key()
_service.delete_task(task_id)
return "", 204
Belangrijk:
request.get_json(silent=True)voorkomt dat Flask zelf een HTML-foutpagina teruggeeft bij ongeldige JSON. Wij willen JSON-fouten.- We gebruiken
PATCHvoor gedeeltelijke updates (alleen velden die je meestuurt).
Validatie en consistente fouten (JSON)
app/core/errors.py
from dataclasses import dataclass
from flask import Flask
@dataclass
class ApiError(Exception):
status_code: int
code: str
message: str
details: dict | None = None
def register_error_handlers(app: Flask) -> None:
@app.errorhandler(ApiError)
def handle_api_error(err: ApiError):
payload = {
"error": {
"code": err.code,
"message": err.message,
}
}
if err.details:
payload["error"]["details"] = err.details
return payload, err.status_code
@app.errorhandler(404)
def handle_404(_):
return {"error": {"code": "not_found", "message": "Resource niet gevonden"}}, 404
@app.errorhandler(405)
def handle_405(_):
return {"error": {"code": "method_not_allowed", "message": "Methode niet toegestaan"}}, 405
@app.errorhandler(500)
def handle_500(_):
# In productie wil je hier geen interne details lekken.
return {"error": {"code": "internal_error", "message": "Interne serverfout"}}, 500
Waarom dit patroon:
- Je API blijft consistent: fouten zijn altijd JSON met
error.codeenerror.message. - Je kunt later
detailstoevoegen voor veldfouten, zonder je basisstructuur te veranderen.
Service-laag met validatie: app/services/tasks_service.py
from typing import Any, Optional
from app.core.errors import ApiError
class TasksService:
def __init__(self, repo):
self.repo = repo
def _parse_int(self, value: str, field: str) -> int:
try:
return int(value)
except (TypeError, ValueError):
raise ApiError(400, "bad_request", f"'{field}' moet een geheel getal zijn")
def _parse_bool_str(self, value: Optional[str], field: str) -> Optional[bool]:
if value is None:
return None
v = value.strip().lower()
if v in ("true", "1", "yes"):
return True
if v in ("false", "0", "no"):
return False
raise ApiError(400, "bad_request", f"'{field}' moet true of false zijn")
def list_tasks(self, *, limit: str, offset: str, done: Optional[str]):
limit_i = self._parse_int(limit, "limit")
offset_i = self._parse_int(offset, "offset")
if limit_i < 1 or limit_i > 200:
raise ApiError(400, "bad_request", "'limit' moet tussen 1 en 200 liggen")
if offset_i < 0:
raise ApiError(400, "bad_request", "'offset' mag niet negatief zijn")
done_b = self._parse_bool_str(done, "done")
tasks = self.repo.list_tasks()
if done_b is not None:
tasks = [t for t in tasks if t["done"] is done_b]
total = len(tasks)
page = tasks[offset_i: offset_i + limit_i]
return {
"items": page,
"pagination": {
"limit": limit_i,
"offset": offset_i,
"total": total,
},
}
def create_task(self, data: dict[str, Any]):
title = data.get("title")
if not isinstance(title, str) or not title.strip():
raise ApiError(
400,
"validation_error",
"Validatiefout",
details={"title": "Titel is verplicht en moet een niet-lege tekst zijn"},
)
task = self.repo.create_task(title=title.strip())
return task
def get_task(self, task_id: int):
task = self.repo.get_task(task_id)
if not task:
raise ApiError(404, "not_found", f"Taak met id={task_id} bestaat niet")
return task
def patch_task(self, task_id: int, data: dict[str, Any]):
allowed = {"title", "done"}
unknown = set(data.keys()) - allowed
if unknown:
raise ApiError(
400,
"validation_error",
"Onbekende velden in request",
details={"unknown_fields": sorted(list(unknown))},
)
title = data.get("title", None)
done = data.get("done", None)
if title is not None and (not isinstance(title, str) or not title.strip()):
raise ApiError(
400,
"validation_error",
"Validatiefout",
details={"title": "Als je 'title' meestuurt, moet het een niet-lege tekst zijn"},
)
if done is not None and not isinstance(done, bool):
raise ApiError(
400,
"validation_error",
"Validatiefout",
details={"done": "Als je 'done' meestuurt, moet het een boolean zijn"},
)
updated = self.repo.update_task(task_id, title=title.strip() if isinstance(title, str) else None, done=done)
if not updated:
raise ApiError(404, "not_found", f"Taak met id={task_id} bestaat niet")
return updated
def delete_task(self, task_id: int):
ok = self.repo.delete_task(task_id)
if not ok:
raise ApiError(404, "not_found", f"Taak met id={task_id} bestaat niet")
Wat hier “gevorderd beginners”-waardig is:
- Validatie gebeurt niet in routes, maar in een service-laag.
- Fouten zijn expliciet en consistent.
- Paginering is ingebouwd en geeft ook
totalterug.
Paginering, filtering en sortering
We hebben paginering en filtering al toegevoegd:
limitbepaalt hoeveel items per paginaoffsetis het startpuntdone=truefiltert op afgeronde taken
Voorbeeld:
curl -s -H "X-API-Key: supergeheime-sleutel" "http://127.0.0.1:5000/api/v1/tasks?limit=10&offset=0&done=false" | python -m json.tool
Sortering kun je toevoegen door bijvoorbeeld sort=created_at en order=asc|desc te accepteren. Een simpele aanpak is sorteren in Python op sleutel, maar bij grotere datasets wil je dit in de database doen. Het ontwerpprincipe blijft: de API biedt parameters, de implementatie kan later veranderen.
Versiebeheer van je API
We gebruiken al /api/v1. Dit is een pragmatische keuze:
- Je kunt later
/api/v2introduceren met breaking changes. - Oude clients blijven werken zolang je v1 blijft ondersteunen.
Alternatieven:
- Versie in header (complexer)
- Versie in querystring (minder gebruikelijk)
Voor veel teams is een padversie (/v1) het meest duidelijk.
Authenticatie met API-sleutels (eenvoudig maar nuttig)
We hebben X-API-Key toegevoegd. Dit is geen volledige beveiliging (zeker niet voor eindgebruikers), maar wel nuttig voor:
- Interne services
- Snelle bescherming tegen “open” endpoints
- Rate limiting (later) per sleutel
Test zonder sleutel:
curl -i http://127.0.0.1:5000/api/v1/tasks
Je verwacht 401 met JSON-fout.
Test met sleutel:
curl -i -H "X-API-Key: supergeheime-sleutel" http://127.0.0.1:5000/api/v1/tasks
Belangrijke nuance: API-sleutels moeten over HTTPS worden verstuurd in echte omgevingen, anders kan iemand op het netwerk de sleutel meelezen.
Logging en observatie
Flask logt standaard requests, maar je wilt vaak extra context. Een eenvoudige stap is loggen wanneer taken worden aangemaakt of aangepast. Je kunt dit in de service-laag doen.
Pas app/services/tasks_service.py aan door logging toe te voegen:
import logging
logger = logging.getLogger(__name__)
En bijvoorbeeld in create_task:
logger.info("Taak aangemaakt: title=%s", title.strip())
Om loggingniveau te zetten kun je in create_app() configureren:
import logging
logging.basicConfig(level=logging.INFO)
Waarom logging belangrijk is:
- Je kunt problemen reproduceren.
- Je ziet foutpatronen.
- Je kunt later metrics en tracing toevoegen.
Testen met pytest
Testen is waar veel beginners afhaken, maar het is juist bij API’s enorm waardevol. Je test:
- statuscodes
- response-structuur
- validatiegedrag
Testconfiguratie
Maak tests/test_api.py:
import os
import pytest
from app import create_app
@pytest.fixture()
def client(monkeypatch):
monkeypatch.setenv("API_KEY", "testkey")
app = create_app()
app.config["TESTING"] = True
return app.test_client()
def test_health(client):
res = client.get("/health")
assert res.status_code == 200
data = res.get_json()
assert data["status"] == "ok"
def test_unauthorized_without_key(client):
res = client.get("/api/v1/tasks")
assert res.status_code == 401
data = res.get_json()
assert data["error"]["code"] == "unauthorized"
def test_create_and_get_task(client):
res = client.post(
"/api/v1/tasks",
headers={"X-API-Key": "testkey"},
json={"title": "Boodschappen doen"},
)
assert res.status_code == 201
created = res.get_json()
assert created["title"] == "Boodschappen doen"
assert created["done"] is False
task_id = created["id"]
res2 = client.get(f"/api/v1/tasks/{task_id}", headers={"X-API-Key": "testkey"})
assert res2.status_code == 200
fetched = res2.get_json()
assert fetched["id"] == task_id
def test_validation_error_on_empty_title(client):
res = client.post(
"/api/v1/tasks",
headers={"X-API-Key": "testkey"},
json={"title": " "},
)
assert res.status_code == 400
data = res.get_json()
assert data["error"]["code"] == "validation_error"
assert "title" in data["error"]["details"]
Draai tests:
pytest -q
Waarom dit werkt:
app.test_client()simuleert HTTP-verkeer zonder echte server.- Met
monkeypatch.setenvzet je de API-sleutel voor deze test-run.
CORS en clients
Als je browser-gebaseerde clients hebt (bijvoorbeeld een webapp op een andere domeinnaam/poort), dan krijg je te maken met CORS. In Flask kun je dit oplossen met een extra dependency, maar omdat we het simpel houden, volstaat het conceptueel:
- CORS is een browserbeveiliging, geen serverbeveiliging.
- Je API moet expliciet toestaan welke origins mogen praten met jouw API.
Als je dit echt nodig hebt, installeer dan een CORS-extensie en configureer strikt welke origins zijn toegestaan. Laat het niet “open” in productie.
Productie-achtige run met gunicorn
Flask’s ingebouwde server is bedoeld voor ontwikkeling. Voor een productie-achtige run gebruik je gunicorn.
Start gunicorn:
gunicorn -w 2 -b 0.0.0.0:8000 wsgi:app
Uitleg:
-w 2: twee worker-processen (afhankelijk van CPU en workload)-b 0.0.0.0:8000: bind op alle interfaces op poort 8000wsgi:app: importeerappuitwsgi.py
Test:
curl -i http://127.0.0.1:8000/health
Let op: in echte productie zet je een reverse proxy ervoor (bijvoorbeeld nginx) en gebruik je HTTPS.
Handmatige API-tests met curl (praktijk)
Taak aanmaken
curl -i \
-H "Content-Type: application/json" \
-H "X-API-Key: supergeheime-sleutel" \
-d '{"title":"API tutorial afmaken"}' \
http://127.0.0.1:5000/api/v1/tasks
Verwacht 201 Created en een JSON-body met id.
Takenlijst ophalen
curl -s \
-H "X-API-Key: supergeheime-sleutel" \
"http://127.0.0.1:5000/api/v1/tasks?limit=5&offset=0" | python -m json.tool
Taak markeren als gedaan
Vervang <id> door een echt id:
curl -i \
-H "Content-Type: application/json" \
-H "X-API-Key: supergeheime-sleutel" \
-d '{"done": true}' \
http://127.0.0.1:5000/api/v1/tasks/<id>
Taak verwijderen
curl -i \
-H "X-API-Key: supergeheime-sleutel" \
-X DELETE \
http://127.0.0.1:5000/api/v1/tasks/<id>
Verwacht 204 No Content.
Veelgemaakte fouten en hoe je ze voorkomt
-
HTML-foutpagina’s teruggeven
- Oplossing: eigen error handlers en
get_json(silent=True).
- Oplossing: eigen error handlers en
-
Validatie verspreid over routes
- Oplossing: service-laag met duidelijke regels.
-
Geen versie in je API
- Oplossing:
/api/v1vanaf het begin.
- Oplossing:
-
Geen tests
- Oplossing: begin met 3–5 kernscenario’s (health, unauthorized, create/get, validation).
-
Geen consistente response-structuur
- Oplossing: standaardiseer succes- en foutresponses.
Volgende stappen
Als je deze tutorial af hebt, kun je uitbreiden met:
- Database-opslag (bijvoorbeeld SQLite) met een echte repository-implementatie
- Migraties en datamodellen
- Rate limiting per API-sleutel
- OpenAPI-specificatie en documentatiegeneratie
- Echte authenticatie (tokens) en autorisatie (rollen)
- Idempotency keys voor
POST(handig bij retries) - ETags en caching headers voor
GET
Complete code-overzicht (controlelijst)
Als alles klopt, heb je minimaal deze bestanden:
app/__init__.pyapp/core/config.pyapp/core/errors.pyapp/api/routes.pyapp/services/tasks_service.pyapp/storage/memory.pywsgi.pytests/test_api.py.envrequirements.txt
Met deze basis heb je een REST API die niet alleen “werkt”, maar ook al rekening houdt met onderhoudbaarheid, uitbreidbaarheid en betrouwbaarheid.