API: endpoint /data avec pagination et filtres (#78)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+94
-1
@@ -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.filters import FilterError, build_where
|
||||
from src.db import count_marches, query_marches
|
||||
from src.db import schema as duckdb_schema
|
||||
|
||||
bp = Blueprint(
|
||||
@@ -10,6 +13,54 @@ bp = Blueprint(
|
||||
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")
|
||||
def health():
|
||||
@@ -23,3 +74,45 @@ def schema():
|
||||
"""Liste des colonnes disponibles dans le dataset DECP."""
|
||||
cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()]
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user