API: endpoint /data avec pagination et filtres (#78)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-05-13 14:15:19 +02:00
parent 7571b9a984
commit 6c591aeb08
2 changed files with 141 additions and 1 deletions
+94 -1
View File
@@ -1,6 +1,9 @@
from flask_smorest import Blueprint from flask import request
from flask_smorest import Blueprint, abort
from src.api.auth import require_token from src.api.auth import require_token
from src.api.filters import FilterError, build_where
from src.db import count_marches, query_marches
from src.db import schema as duckdb_schema from src.db import schema as duckdb_schema
bp = Blueprint( bp = Blueprint(
@@ -10,6 +13,54 @@ bp = Blueprint(
description="API privée decp.info — accès tabulaire aux marchés publics.", description="API privée decp.info — accès tabulaire aux marchés publics.",
) )
MAX_PAGE_SIZE = 1000
def _parse_pagination():
try:
page = int(request.args.get("page", "1"))
page_size = int(request.args.get("page_size", "50"))
except ValueError:
abort(400, message="page et page_size doivent être des entiers")
if page < 1:
abort(400, message="page doit être >= 1")
if page_size < 1 or page_size > MAX_PAGE_SIZE:
abort(
400,
message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]",
)
return page, page_size
def _parse_columns():
raw = request.args.get("columns")
if not raw:
return None
cols = [c.strip() for c in raw.split(",") if c.strip()]
unknown = [c for c in cols if c not in duckdb_schema]
if unknown:
abort(400, message=f"Colonnes inconnues : {unknown}")
return cols
def _build_links(page, page_size, total):
base = request.path
qs = request.args.to_dict(flat=False)
qs.pop("page", None)
def url_for(p):
from urllib.parse import urlencode
params = [(k, v) for k, vs in qs.items() for v in vs]
params.append(("page", str(p)))
return f"{base}?{urlencode(params)}"
prev_url = url_for(page - 1) if page > 1 else None
next_url = None
if total is None or page * page_size < total:
next_url = url_for(page + 1)
return {"prev": prev_url, "next": next_url}
@bp.route("/health") @bp.route("/health")
def health(): def health():
@@ -23,3 +74,45 @@ def schema():
"""Liste des colonnes disponibles dans le dataset DECP.""" """Liste des colonnes disponibles dans le dataset DECP."""
cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()]
return {"columns": cols} return {"columns": cols}
@bp.route("/data")
@require_token
def data():
"""Endpoint tabulaire : filtres dynamiques sur les colonnes DECP."""
import polars as pl
import polars.selectors as cs
page, page_size = _parse_pagination()
columns = _parse_columns()
count = request.args.get("count", "true").lower() != "false"
try:
where_sql, params, order_sql = build_where(
list(request.args.items(multi=True)), duckdb_schema
)
except FilterError as e:
abort(400, message=str(e), errors={"field": e.field})
df = query_marches(
where_sql=where_sql,
params=params,
columns=columns,
order_by=order_sql,
limit=page_size,
offset=(page - 1) * page_size,
)
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
df_ready = df.with_columns(cs.temporal().cast(pl.String))
total = count_marches(where_sql, params) if count else None
meta = {"page": page, "page_size": page_size}
if total is not None:
meta["total"] = total
return {
"data": df_ready.to_dicts(),
"meta": meta,
"links": _build_links(page, page_size, total),
}
+47
View File
@@ -0,0 +1,47 @@
def test_data_without_token_returns_401(api_client):
client, _ = api_client
resp = client.get("/api/v1/data")
assert resp.status_code == 401
def test_data_default_pagination(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert set(body.keys()) >= {"data", "meta", "links"}
assert isinstance(body["data"], list)
assert len(body["data"]) <= 50 # default page_size
assert body["meta"]["page"] == 1
assert body["meta"]["page_size"] == 50
assert "total" in body["meta"]
def test_data_count_false_omits_total(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?count=false", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert "total" not in body["meta"]
def test_data_page_size_max_enforced(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page_size=5000", headers=valid_token_header)
assert resp.status_code == 400
def test_data_page_size_below_min_rejected(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page_size=0", headers=valid_token_header)
assert resp.status_code == 400
def test_data_pagination_links(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page=1&page_size=1", headers=valid_token_header)
body = resp.get_json()
assert body["links"]["prev"] is None
if body["meta"]["total"] > 1:
assert body["links"]["next"] is not None
assert "page=2" in body["links"]["next"]