feat: MVP FastAPI REST API
Endpoints:
- GET /health — health check
- GET /api/v1/regelingen — lijst (met type/status filter)
- GET /api/v1/regelingen/{bwb_id} — metadata
- GET /api/v1/regelingen/{bwb_id}/tekst — volledige Markdown
- GET /api/v1/regelingen/{bwb_id}/artikelen — artikellijst
- GET /api/v1/regelingen/{bwb_id}/artikelen/{nr} — specifiek artikel
- GET /api/v1/regelingen/{bwb_id}/versies — beschikbare toestanden
- GET /api/v1/regelingen/{bwb_id}/diff?van=...&tot=... — versievergelijking
- GET /api/v1/zoeken?q=... — full-text zoeken
- Swagger docs op /api/docs
Getest: zoek "godsdienst" → vindt art. 1, 6, 23 Grondwet.
Sluit #34
This commit is contained in:
parent
a7e4a4bc16
commit
21be1367d1
3 changed files with 468 additions and 0 deletions
181
src/wetgit/api/app.py
Normal file
181
src/wetgit/api/app.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""WetGit REST API — FastAPI applicatie.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uvicorn wetgit.api.app:app --host 127.0.0.1 --port 8002
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from wetgit import __version__
|
||||||
|
from wetgit.api.data import RegelingStore
|
||||||
|
from wetgit.api.models import (
|
||||||
|
ArtikelDetail,
|
||||||
|
ArtikelItem,
|
||||||
|
DiffResult,
|
||||||
|
HealthResponse,
|
||||||
|
RegelingDetail,
|
||||||
|
RegelingMeta,
|
||||||
|
VersieItem,
|
||||||
|
ZoekResultaat,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO_PATH = Path(os.environ.get("WETGIT_REPO", "/tmp/wetgit-index-test"))
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="WetGit API",
|
||||||
|
description="Nederlandse wetgeving als code — REST API",
|
||||||
|
version=__version__,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["GET"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
store = RegelingStore(REPO_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health ---
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
def health() -> HealthResponse:
|
||||||
|
"""Health check."""
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok",
|
||||||
|
version=__version__,
|
||||||
|
regelingen=len(store.index),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Regelingen ---
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen", response_model=list[RegelingMeta])
|
||||||
|
def list_regelingen(
|
||||||
|
type: str | None = Query(None, description="Filter op type (wet, amvb, etc.)"),
|
||||||
|
status: str | None = Query(None, description="Filter op status (geldend, vervallen)"),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Lijst van alle regelingen."""
|
||||||
|
regelingen = store.list_regelingen()
|
||||||
|
if type:
|
||||||
|
regelingen = [r for r in regelingen if r.get("type") == type]
|
||||||
|
if status:
|
||||||
|
regelingen = [r for r in regelingen if r.get("status") == status]
|
||||||
|
return regelingen
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}", response_model=RegelingMeta)
|
||||||
|
def get_regeling(bwb_id: str) -> dict:
|
||||||
|
"""Metadata van een regeling."""
|
||||||
|
regeling = store.get_regeling(bwb_id)
|
||||||
|
if not regeling:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
|
||||||
|
return regeling
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}/tekst")
|
||||||
|
def get_tekst(bwb_id: str) -> dict:
|
||||||
|
"""Volledige tekst van een regeling als Markdown."""
|
||||||
|
tekst = store.get_tekst(bwb_id)
|
||||||
|
if tekst is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
|
||||||
|
return {"bwb_id": bwb_id, "tekst": tekst}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Artikelen ---
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}/artikelen", response_model=list[ArtikelItem])
|
||||||
|
def list_artikelen(bwb_id: str) -> list[dict]:
|
||||||
|
"""Lijst van alle artikelen in een regeling."""
|
||||||
|
artikelen = store.get_artikelen(bwb_id)
|
||||||
|
if not artikelen:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
|
||||||
|
return artikelen
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}/artikelen/{nummer}", response_model=ArtikelDetail)
|
||||||
|
def get_artikel(bwb_id: str, nummer: str) -> dict:
|
||||||
|
"""Eén specifiek artikel."""
|
||||||
|
artikel = store.get_artikel(bwb_id, nummer)
|
||||||
|
if not artikel:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Artikel {nummer} niet gevonden in {bwb_id}",
|
||||||
|
)
|
||||||
|
return artikel
|
||||||
|
|
||||||
|
|
||||||
|
# --- Versies & Diff ---
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}/versies", response_model=list[VersieItem])
|
||||||
|
def list_versies(bwb_id: str) -> list[dict]:
|
||||||
|
"""Beschikbare versies (toestanden) van een regeling."""
|
||||||
|
versies = store.get_versies(bwb_id)
|
||||||
|
if not versies:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
|
||||||
|
return versies
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/regelingen/{bwb_id}/diff", response_model=DiffResult)
|
||||||
|
def get_diff(
|
||||||
|
bwb_id: str,
|
||||||
|
van: str = Query(..., description="Startdatum (YYYY-MM-DD)"),
|
||||||
|
tot: str = Query(..., description="Einddatum (YYYY-MM-DD)"),
|
||||||
|
) -> dict:
|
||||||
|
"""Vergelijk twee versies van een regeling."""
|
||||||
|
result = store.get_diff(bwb_id, van, tot)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Geen diff beschikbaar voor {bwb_id} tussen {van} en {tot}",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zoeken ---
|
||||||
|
|
||||||
|
@app.get("/api/v1/zoeken", response_model=list[ZoekResultaat])
|
||||||
|
def zoeken(
|
||||||
|
q: str = Query(..., min_length=2, description="Zoekterm"),
|
||||||
|
type: str | None = Query(None, description="Filter op type"),
|
||||||
|
limit: int = Query(20, ge=1, le=100, description="Max resultaten"),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Doorzoek alle wetgeving (full-text)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
resultaten: list[dict] = []
|
||||||
|
|
||||||
|
for regeling in store.list_regelingen():
|
||||||
|
if type and regeling.get("type") != type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tekst = store.get_tekst(regeling["bwb_id"])
|
||||||
|
if tekst is None or q.lower() not in tekst.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Zoek in welk artikel de match zit
|
||||||
|
current_artikel = ""
|
||||||
|
for line in tekst.split("\n"):
|
||||||
|
if line.startswith("### Artikel"):
|
||||||
|
current_artikel = line.replace("### ", "")
|
||||||
|
if q.lower() in line.lower() and current_artikel:
|
||||||
|
resultaten.append({
|
||||||
|
"bwb_id": regeling["bwb_id"],
|
||||||
|
"titel": regeling.get("titel", ""),
|
||||||
|
"artikel": current_artikel,
|
||||||
|
"context": line.strip()[:200],
|
||||||
|
})
|
||||||
|
if len(resultaten) >= limit:
|
||||||
|
return resultaten
|
||||||
|
|
||||||
|
return resultaten
|
||||||
209
src/wetgit/api/data.py
Normal file
209
src/wetgit/api/data.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
"""Data-laag — leest regelingen uit de Markdown bestanden."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
class RegelingStore:
|
||||||
|
"""Leest en cached regelingen uit de wetgit/rijk repo."""
|
||||||
|
|
||||||
|
def __init__(self, repo_path: Path) -> None:
|
||||||
|
self.repo_path = repo_path
|
||||||
|
self._index: list[dict] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index(self) -> list[dict]:
|
||||||
|
if self._index is None:
|
||||||
|
self._index = self._load_index()
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""Herlaad de index."""
|
||||||
|
self._index = None
|
||||||
|
|
||||||
|
def _load_index(self) -> list[dict]:
|
||||||
|
"""Laad index.json of scan de repo."""
|
||||||
|
index_path = self.repo_path / "index.json"
|
||||||
|
if index_path.exists():
|
||||||
|
data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
return data.get("regelingen", [])
|
||||||
|
|
||||||
|
# Fallback: scan de repo
|
||||||
|
from wetgit.pipeline.indexer import generate_index
|
||||||
|
return generate_index(self.repo_path)
|
||||||
|
|
||||||
|
def list_regelingen(self) -> list[dict]:
|
||||||
|
"""Lijst van alle regelingen."""
|
||||||
|
return self.index
|
||||||
|
|
||||||
|
def get_regeling(self, bwb_id: str) -> dict | None:
|
||||||
|
"""Haal metadata op voor één regeling."""
|
||||||
|
for r in self.index:
|
||||||
|
if r["bwb_id"] == bwb_id:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_tekst(self, bwb_id: str) -> str | None:
|
||||||
|
"""Haal de volledige Markdown tekst op."""
|
||||||
|
md_path = self._find_md(bwb_id)
|
||||||
|
if md_path is None:
|
||||||
|
return None
|
||||||
|
return md_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def get_artikelen(self, bwb_id: str) -> list[dict]:
|
||||||
|
"""Haal alle artikelen op als lijst."""
|
||||||
|
tekst = self.get_tekst(bwb_id)
|
||||||
|
if tekst is None:
|
||||||
|
return []
|
||||||
|
return self._extract_artikelen(tekst)
|
||||||
|
|
||||||
|
def get_artikel(self, bwb_id: str, nummer: str) -> dict | None:
|
||||||
|
"""Haal één specifiek artikel op."""
|
||||||
|
tekst = self.get_tekst(bwb_id)
|
||||||
|
if tekst is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = rf"(### Artikel {re.escape(nummer)}\b.*?)(?=\n### Artikel |\n## |\Z)"
|
||||||
|
match = re.search(pattern, tekst, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
art_text = match.group(1).strip()
|
||||||
|
titel = None
|
||||||
|
lines = art_text.split("\n")
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.startswith("*") and line.endswith("*") and not line.startswith("**"):
|
||||||
|
titel = line.strip("*").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nummer": nummer,
|
||||||
|
"titel": titel,
|
||||||
|
"tekst": art_text,
|
||||||
|
"bwb_id": bwb_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_versies(self, bwb_id: str) -> list[dict]:
|
||||||
|
"""Haal beschikbare versies op via git log."""
|
||||||
|
md_path = self._find_md(bwb_id)
|
||||||
|
if md_path is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rel_path = md_path.relative_to(self.repo_path)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "--format=%ai %s", "--follow", "--", str(rel_path)],
|
||||||
|
cwd=self.repo_path, capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
versies = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
datum = line.split(" ")[0]
|
||||||
|
versies.append({"datum": datum})
|
||||||
|
return versies
|
||||||
|
|
||||||
|
def get_diff(self, bwb_id: str, van: str, tot: str) -> dict | None:
|
||||||
|
"""Vergelijk twee versies."""
|
||||||
|
md_path = self._find_md(bwb_id)
|
||||||
|
if md_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rel_path = md_path.relative_to(self.repo_path)
|
||||||
|
|
||||||
|
# Zoek commits bij de datums
|
||||||
|
try:
|
||||||
|
log_result = subprocess.run(
|
||||||
|
["git", "log", "--format=%H %ai", "--follow", "--", str(rel_path)],
|
||||||
|
cwd=self.repo_path, capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
commits: list[tuple[str, str]] = []
|
||||||
|
for line in log_result.stdout.strip().split("\n"):
|
||||||
|
if line:
|
||||||
|
parts = line.split(" ")
|
||||||
|
commits.append((parts[0], parts[1]))
|
||||||
|
|
||||||
|
commit_van = self._find_commit_for_date(commits, van)
|
||||||
|
commit_tot = self._find_commit_for_date(commits, tot)
|
||||||
|
|
||||||
|
if not commit_van or not commit_tot or commit_van == commit_tot:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
diff_result = subprocess.run(
|
||||||
|
["git", "diff", commit_van, commit_tot, "--", str(rel_path)],
|
||||||
|
cwd=self.repo_path, capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
diff_text = diff_result.stdout
|
||||||
|
added = sum(1 for l in diff_text.split("\n") if l.startswith("+") and not l.startswith("+++"))
|
||||||
|
removed = sum(1 for l in diff_text.split("\n") if l.startswith("-") and not l.startswith("---"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bwb_id": bwb_id,
|
||||||
|
"van": van,
|
||||||
|
"tot": tot,
|
||||||
|
"diff": diff_text,
|
||||||
|
"regels_toegevoegd": added,
|
||||||
|
"regels_verwijderd": removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_md(self, bwb_id: str) -> Path | None:
|
||||||
|
"""Zoek het Markdown bestand voor een BWB-ID."""
|
||||||
|
for md in self.repo_path.rglob("README.md"):
|
||||||
|
if bwb_id in str(md):
|
||||||
|
return md
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_commit_for_date(
|
||||||
|
self, commits: list[tuple[str, str]], target: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Vind de commit die het dichtst bij de datum ligt."""
|
||||||
|
for commit_hash, date_str in commits:
|
||||||
|
if date_str <= target:
|
||||||
|
return commit_hash
|
||||||
|
return commits[-1][0] if commits else None
|
||||||
|
|
||||||
|
def _extract_artikelen(self, tekst: str) -> list[dict]:
|
||||||
|
"""Extraheer artikelen uit Markdown tekst."""
|
||||||
|
artikelen = []
|
||||||
|
pattern = r"### Artikel (\S+)(.*?)(?=\n### Artikel |\n## |\Z)"
|
||||||
|
|
||||||
|
for match in re.finditer(pattern, tekst, re.DOTALL):
|
||||||
|
nummer = match.group(1)
|
||||||
|
body = match.group(2).strip()
|
||||||
|
|
||||||
|
titel = None
|
||||||
|
preview = ""
|
||||||
|
for line in body.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("*") and line.endswith("*") and not line.startswith("**"):
|
||||||
|
titel = line.strip("*").strip()
|
||||||
|
else:
|
||||||
|
preview = line[:200]
|
||||||
|
break
|
||||||
|
|
||||||
|
artikelen.append({
|
||||||
|
"nummer": nummer,
|
||||||
|
"titel": titel,
|
||||||
|
"preview": preview,
|
||||||
|
})
|
||||||
|
|
||||||
|
return artikelen
|
||||||
78
src/wetgit/api/models.py
Normal file
78
src/wetgit/api/models.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Pydantic models voor de REST API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RegelingMeta(BaseModel):
|
||||||
|
"""Metadata van een regeling."""
|
||||||
|
|
||||||
|
bwb_id: str
|
||||||
|
titel: str
|
||||||
|
citeertitel: str | None = None
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
datum_inwerkingtreding: str | None = None
|
||||||
|
bron: str
|
||||||
|
pad: str
|
||||||
|
artikelen: int
|
||||||
|
structuur_elementen: int
|
||||||
|
|
||||||
|
|
||||||
|
class RegelingDetail(RegelingMeta):
|
||||||
|
"""Regeling met volledige tekst."""
|
||||||
|
|
||||||
|
tekst: str
|
||||||
|
|
||||||
|
|
||||||
|
class ArtikelItem(BaseModel):
|
||||||
|
"""Artikel in een lijst."""
|
||||||
|
|
||||||
|
nummer: str
|
||||||
|
titel: str | None = None
|
||||||
|
preview: str = Field(description="Eerste 200 tekens van het artikel")
|
||||||
|
|
||||||
|
|
||||||
|
class ArtikelDetail(BaseModel):
|
||||||
|
"""Volledig artikel."""
|
||||||
|
|
||||||
|
nummer: str
|
||||||
|
titel: str | None = None
|
||||||
|
tekst: str
|
||||||
|
bwb_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class VersieItem(BaseModel):
|
||||||
|
"""Beschikbare versie (toestand)."""
|
||||||
|
|
||||||
|
datum: str
|
||||||
|
geldig_tot: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiffResult(BaseModel):
|
||||||
|
"""Resultaat van een versievergelijking."""
|
||||||
|
|
||||||
|
bwb_id: str
|
||||||
|
van: str
|
||||||
|
tot: str
|
||||||
|
diff: str
|
||||||
|
regels_toegevoegd: int
|
||||||
|
regels_verwijderd: int
|
||||||
|
|
||||||
|
|
||||||
|
class ZoekResultaat(BaseModel):
|
||||||
|
"""Eén zoekresultaat."""
|
||||||
|
|
||||||
|
bwb_id: str
|
||||||
|
titel: str
|
||||||
|
artikel: str
|
||||||
|
context: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Health check response."""
|
||||||
|
|
||||||
|
status: str = "ok"
|
||||||
|
version: str
|
||||||
|
regelingen: int
|
||||||
Loading…
Add table
Reference in a new issue