diff --git a/src/api/routes.py b/src/api/routes.py index a4728b3..0d5d4a8 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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), + } diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py new file mode 100644 index 0000000..b45e0c7 --- /dev/null +++ b/tests/api/test_endpoints_data.py @@ -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"]