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.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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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