0363e89301
14 tâches TDD couvrant deps, tokens_db, CLI, filters, auth, blueprint, endpoints (/health, /schema, /data), tracking SQLite + Matomo, OpenAPI, changelog, mention page À propos. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1993 lines
55 KiB
Markdown
1993 lines
55 KiB
Markdown
# API privée decp.info — Plan d'implémentation
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Livrer une API HTTP privée tabulaire `/api/v1/data` protégée par tokens Bearer, documentée via OpenAPI/Swagger, avec suivi de consommation (Matomo + compteurs SQLite).
|
|
|
|
**Architecture:** Blueprint flask-smorest monté sur le serveur Flask existant (sous Dash), partage la connexion DuckDB read-only de `src/db.py`. Tokens stockés hashés (SHA-256) dans `users.sqlite`, CLI d'administration. Suivi async par worker thread (SQLite) + httpx fire-and-forget (Matomo).
|
|
|
|
**Tech Stack:** Flask, flask-smorest, marshmallow, DuckDB (existant), SQLite (stdlib), httpx (existant), Polars (existant), pytest.
|
|
|
|
**Spec de référence :** `docs/superpowers/specs/2026-05-13-api-privee-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**À créer :**
|
|
|
|
```
|
|
src/api/
|
|
├── __init__.py # init_api(server)
|
|
├── routes.py # endpoints /data, /schema, /health
|
|
├── schemas.py # marshmallow request/response schemas
|
|
├── filters.py # parsing & validation des filtres
|
|
├── auth.py # @require_token
|
|
├── tracking.py # worker SQLite + Matomo httpx
|
|
├── tokens_db.py # CRUD api_tokens
|
|
└── tokens_cli.py # python -m src.api.tokens_cli ...
|
|
|
|
tests/api/
|
|
├── __init__.py
|
|
├── conftest.py # fixtures api_client, valid_token_header, revoked_token_header
|
|
├── test_tokens_db.py
|
|
├── test_tokens_cli.py
|
|
├── test_filters.py
|
|
├── test_auth.py
|
|
├── test_health.py
|
|
├── test_endpoints_schema.py
|
|
├── test_endpoints_data.py
|
|
└── test_tracking.py
|
|
```
|
|
|
|
**À modifier :**
|
|
|
|
- `pyproject.toml` : nouvelles deps + nouvelles variables `pytest-env`
|
|
- `.template.env` : nouvelles variables `MATOMO_*` et `USERS_DB_PATH`
|
|
- `src/app.py` : appel à `init_api(app.server)` après l'init Dash
|
|
- `CHANGELOG.md` : entrée pour la version qui livre l'API
|
|
|
|
---
|
|
|
|
## Task 1: Dépendances et infrastructure de test
|
|
|
|
**Files:**
|
|
|
|
- Modify: `pyproject.toml`
|
|
- Modify: `.template.env`
|
|
- Create: `src/api/__init__.py` (vide)
|
|
- Create: `tests/api/__init__.py` (vide)
|
|
|
|
- [ ] **Step 1: Ajouter `flask-smorest` aux dépendances**
|
|
|
|
Dans `pyproject.toml`, section `[project].dependencies`, ajouter après `"flask-cors>=6.0.2"` :
|
|
|
|
```toml
|
|
"flask-smorest>=0.46.0",
|
|
"marshmallow>=3.20.0",
|
|
```
|
|
|
|
- [ ] **Step 2: Ajouter les variables d'env pytest**
|
|
|
|
Dans `pyproject.toml`, section `[tool.pytest.ini_options].env`, ajouter à la liste :
|
|
|
|
```toml
|
|
"USERS_DB_PATH=tests/users.test.sqlite",
|
|
"MATOMO_TRACKING_ENABLED=false",
|
|
```
|
|
|
|
- [ ] **Step 3: Étendre `.template.env`**
|
|
|
|
Ajouter à la fin de `.template.env` :
|
|
|
|
```bash
|
|
|
|
# API privée
|
|
USERS_DB_PATH=./users.sqlite
|
|
MATOMO_URL=https://analytics.maudry.com/matomo.php
|
|
MATOMO_SITE_ID=14
|
|
MATOMO_TRACKING_ENABLED=true
|
|
```
|
|
|
|
- [ ] **Step 4: Installer les nouvelles dépendances**
|
|
|
|
Run: `source .venv/bin/activate && rtk pip install -e . --group=dev`
|
|
|
|
Expected: install OK, `pip show flask-smorest` shows version.
|
|
|
|
- [ ] **Step 5: Créer les packages vides**
|
|
|
|
Créer `src/api/__init__.py` avec contenu vide (juste un commentaire).
|
|
|
|
```python
|
|
# decp.info — API privée. init_api(server) sera ajouté Task 6.
|
|
```
|
|
|
|
Créer `tests/api/__init__.py` avec contenu vide.
|
|
|
|
- [ ] **Step 6: Vérifier que pytest tourne toujours**
|
|
|
|
Run: `rtk pre-commit run --all-files`
|
|
Expected: PASS (formatages éventuels appliqués).
|
|
|
|
Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -20`
|
|
Expected: la collection se fait sans erreur (peu importe le nb de tests).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
rtk git add pyproject.toml .template.env src/api/__init__.py tests/api/__init__.py
|
|
rtk git commit -m "API: dépendances et squelette de package (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Module `tokens_db` (SQLite CRUD)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/api/tokens_db.py`
|
|
- Create: `tests/api/conftest.py`
|
|
- Create: `tests/api/test_tokens_db.py`
|
|
|
|
- [ ] **Step 1: Écrire la fixture `temp_db` partagée**
|
|
|
|
Créer `tests/api/conftest.py` :
|
|
|
|
```python
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db(tmp_path, monkeypatch):
|
|
"""Une SQLite éphémère pour les tests qui modifient la DB."""
|
|
db_path = tmp_path / "users.test.sqlite"
|
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
|
from src.api import tokens_db
|
|
tokens_db.init_schema(db_path)
|
|
return db_path
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire les tests de `tokens_db`**
|
|
|
|
Créer `tests/api/test_tokens_db.py` :
|
|
|
|
```python
|
|
import sqlite3
|
|
|
|
from src.api import tokens_db
|
|
|
|
|
|
def test_init_schema_creates_table(temp_db):
|
|
with sqlite3.connect(str(temp_db)) as conn:
|
|
rows = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'"
|
|
).fetchall()
|
|
assert rows == [("api_tokens",)]
|
|
|
|
|
|
def test_create_token_returns_plaintext_and_stores_hash(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "test-label")
|
|
assert token.startswith("decpinfo_")
|
|
assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars
|
|
assert token_id >= 1
|
|
|
|
with sqlite3.connect(str(temp_db)) as conn:
|
|
row = conn.execute(
|
|
"SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?",
|
|
(token_id,),
|
|
).fetchone()
|
|
assert row[1] == "test-label"
|
|
assert row[2] == 0
|
|
assert row[0] != token # stocké en clair impossible
|
|
assert len(row[0]) == 64 # sha256 hex
|
|
|
|
|
|
def test_get_token_by_plaintext_returns_row(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
|
assert row is not None
|
|
assert row["id"] == token_id
|
|
assert row["label"] == "x"
|
|
assert row["revoked_at"] is None
|
|
|
|
|
|
def test_get_token_unknown_returns_none(temp_db):
|
|
assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None
|
|
|
|
|
|
def test_revoke_token_sets_revoked_at(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
|
tokens_db.revoke_token(temp_db, token_id)
|
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
|
assert row["revoked_at"] is not None
|
|
|
|
|
|
def test_increment_usage_updates_counter_and_timestamp(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
|
tokens_db.increment_usage(temp_db, token_id)
|
|
tokens_db.increment_usage(temp_db, token_id)
|
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
|
assert row["count_total"] == 2
|
|
assert row["last_used_at"] is not None
|
|
|
|
|
|
def test_list_tokens_returns_all(temp_db):
|
|
tokens_db.create_token(temp_db, "a")
|
|
tokens_db.create_token(temp_db, "b")
|
|
rows = tokens_db.list_tokens(temp_db)
|
|
assert [r["label"] for r in rows] == ["a", "b"]
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer les tests et confirmer qu'ils échouent**
|
|
|
|
Run: `rtk pytest tests/api/test_tokens_db.py -v`
|
|
Expected: tous les tests FAIL avec `ImportError` ou `AttributeError` (module non écrit).
|
|
|
|
- [ ] **Step 4: Implémenter `tokens_db.py`**
|
|
|
|
Créer `src/api/tokens_db.py` :
|
|
|
|
```python
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
TOKEN_PREFIX = "decpinfo_"
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id INTEGER PRIMARY KEY,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
label TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
last_used_at TEXT,
|
|
count_total INTEGER NOT NULL DEFAULT 0,
|
|
revoked_at TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
|
"""
|
|
|
|
|
|
def _utcnow_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def _hash(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
@contextmanager
|
|
def _connect(db_path):
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def init_schema(db_path) -> None:
|
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
with _connect(db_path) as conn:
|
|
conn.executescript(SCHEMA)
|
|
conn.commit()
|
|
|
|
|
|
def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]:
|
|
token = TOKEN_PREFIX + secrets.token_hex(32)
|
|
with _connect(db_path) as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO api_tokens (token_hash, label, user_id, created_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(_hash(token), label, user_id, _utcnow_iso()),
|
|
)
|
|
conn.commit()
|
|
return token, cur.lastrowid
|
|
|
|
|
|
def get_token_by_plaintext(db_path, token: str) -> dict | None:
|
|
with _connect(db_path) as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM api_tokens WHERE token_hash = ?",
|
|
(_hash(token),),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def revoke_token(db_path, token_id: int) -> None:
|
|
with _connect(db_path) as conn:
|
|
conn.execute(
|
|
"UPDATE api_tokens SET revoked_at = ? WHERE id = ?",
|
|
(_utcnow_iso(), token_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def increment_usage(db_path, token_id: int) -> None:
|
|
with _connect(db_path) as conn:
|
|
conn.execute(
|
|
"UPDATE api_tokens "
|
|
"SET count_total = count_total + 1, last_used_at = ? "
|
|
"WHERE id = ?",
|
|
(_utcnow_iso(), token_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def list_tokens(db_path) -> list[dict]:
|
|
with _connect(db_path) as conn:
|
|
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
|
|
return [dict(r) for r in rows]
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer les tests, confirmer qu'ils passent**
|
|
|
|
Run: `rtk pytest tests/api/test_tokens_db.py -v`
|
|
Expected: PASS (7 tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py
|
|
rtk git add src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py
|
|
rtk git commit -m "API: tokens_db SQLite CRUD (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: CLI `tokens_cli` (create / list / revoke)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/api/tokens_cli.py`
|
|
- Create: `tests/api/test_tokens_cli.py`
|
|
|
|
- [ ] **Step 1: Écrire les tests CLI**
|
|
|
|
Créer `tests/api/test_tokens_cli.py` :
|
|
|
|
```python
|
|
import subprocess
|
|
import sys
|
|
|
|
from src.api import tokens_cli, tokens_db
|
|
|
|
|
|
def _run(args, env):
|
|
return tokens_cli.main(args, env=env)
|
|
|
|
|
|
def test_create_prints_plaintext_token_once(temp_db, capsys):
|
|
rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)})
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "decpinfo_" in out
|
|
tokens = tokens_db.list_tokens(temp_db)
|
|
assert len(tokens) == 1
|
|
assert tokens[0]["label"] == "alice"
|
|
|
|
|
|
def test_list_shows_tokens(temp_db, capsys):
|
|
tokens_db.create_token(temp_db, "alice")
|
|
tokens_db.create_token(temp_db, "bob")
|
|
rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)})
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "alice" in out
|
|
assert "bob" in out
|
|
|
|
|
|
def test_revoke_sets_revoked_at(temp_db, capsys):
|
|
_, token_id = tokens_db.create_token(temp_db, "alice")
|
|
rc = _run(
|
|
["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)}
|
|
)
|
|
assert rc == 0
|
|
tokens = tokens_db.list_tokens(temp_db)
|
|
assert tokens[0]["revoked_at"] is not None
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_tokens_cli.py -v`
|
|
Expected: FAIL (`ImportError`).
|
|
|
|
- [ ] **Step 3: Implémenter `tokens_cli.py`**
|
|
|
|
Créer `src/api/tokens_cli.py` :
|
|
|
|
```python
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
from src.api import tokens_db
|
|
|
|
|
|
def main(argv=None, env=None) -> int:
|
|
env = env if env is not None else os.environ
|
|
parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_create = sub.add_parser("create", help="Créer un token API")
|
|
p_create.add_argument("--label", required=True)
|
|
p_create.add_argument("--user-id", type=int, default=None)
|
|
|
|
sub.add_parser("list", help="Lister les tokens")
|
|
|
|
p_revoke = sub.add_parser("revoke", help="Révoquer un token")
|
|
p_revoke.add_argument("token_id", type=int)
|
|
|
|
args = parser.parse_args(argv)
|
|
db_path = env["USERS_DB_PATH"]
|
|
tokens_db.init_schema(db_path)
|
|
|
|
if args.cmd == "create":
|
|
token, token_id = tokens_db.create_token(db_path, args.label, args.user_id)
|
|
print(f"id={token_id} label={args.label}")
|
|
print(f"token (à conserver, ne sera plus affiché) : {token}")
|
|
return 0
|
|
|
|
if args.cmd == "list":
|
|
rows = tokens_db.list_tokens(db_path)
|
|
if not rows:
|
|
print("(aucun token)")
|
|
return 0
|
|
print(f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked")
|
|
for r in rows:
|
|
print(
|
|
f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} "
|
|
f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} "
|
|
f"{r['revoked_at'] or ''}"
|
|
)
|
|
return 0
|
|
|
|
if args.cmd == "revoke":
|
|
tokens_db.revoke_token(db_path, args.token_id)
|
|
print(f"token id={args.token_id} révoqué")
|
|
return 0
|
|
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(main())
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_tokens_cli.py -v`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 5: Tester manuellement le CLI**
|
|
|
|
```bash
|
|
USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli create --label "test"
|
|
USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli list
|
|
```
|
|
|
|
Expected: token affiché en sortie, puis listé.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/tokens_cli.py tests/api/test_tokens_cli.py
|
|
rtk git add src/api/tokens_cli.py tests/api/test_tokens_cli.py
|
|
rtk git commit -m "API: CLI tokens_cli (create/list/revoke) (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Module `filters` (parsing + génération SQL)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/api/filters.py`
|
|
- Create: `tests/api/test_filters.py`
|
|
|
|
- [ ] **Step 1: Écrire les tests de parsing & validation**
|
|
|
|
Créer `tests/api/test_filters.py` :
|
|
|
|
```python
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from src.api.filters import FilterError, build_where
|
|
|
|
|
|
SCHEMA = pl.Schema(
|
|
{
|
|
"uid": pl.String,
|
|
"objet": pl.String,
|
|
"montant": pl.Float64,
|
|
"annee": pl.Int64,
|
|
"dateNotification": pl.Date,
|
|
}
|
|
)
|
|
|
|
|
|
def test_no_filters_returns_true():
|
|
where, params, order = build_where([], SCHEMA)
|
|
assert where == "TRUE"
|
|
assert params == []
|
|
assert order is None
|
|
|
|
|
|
def test_exact_filter():
|
|
where, params, _ = build_where([("uid__exact", "abc")], SCHEMA)
|
|
assert where == '"uid" = ?'
|
|
assert params == ["abc"]
|
|
|
|
|
|
def test_contains_filter_uses_like_wildcards():
|
|
where, params, _ = build_where(
|
|
[("objet__contains", "informatique")], SCHEMA
|
|
)
|
|
assert where == '"objet" LIKE ?'
|
|
assert params == ["%informatique%"]
|
|
|
|
|
|
def test_notcontains_filter():
|
|
where, params, _ = build_where(
|
|
[("objet__notcontains", "x")], SCHEMA
|
|
)
|
|
assert where == '"objet" NOT LIKE ?'
|
|
assert params == ["%x%"]
|
|
|
|
|
|
def test_comparison_operators_on_int():
|
|
where, params, _ = build_where(
|
|
[("annee__strictly_greater", "2023")], SCHEMA
|
|
)
|
|
assert where == '"annee" > ?'
|
|
assert params == [2024 - 1] # int coercion: 2023
|
|
|
|
|
|
def test_in_filter_splits_on_commas():
|
|
where, params, _ = build_where(
|
|
[("annee__in", "2022,2023,2024")], SCHEMA
|
|
)
|
|
assert where == '"annee" IN (?,?,?)'
|
|
assert params == [2022, 2023, 2024]
|
|
|
|
|
|
def test_notin_filter():
|
|
where, params, _ = build_where(
|
|
[("annee__notin", "2020,2021")], SCHEMA
|
|
)
|
|
assert where == '"annee" NOT IN (?,?)'
|
|
assert params == [2020, 2021]
|
|
|
|
|
|
def test_isnull_filter_ignores_value():
|
|
where, params, _ = build_where(
|
|
[("dateNotification__isnull", "anything")], SCHEMA
|
|
)
|
|
assert where == '"dateNotification" IS NULL'
|
|
assert params == []
|
|
|
|
|
|
def test_isnotnull_filter():
|
|
where, params, _ = build_where(
|
|
[("dateNotification__isnotnull", "")], SCHEMA
|
|
)
|
|
assert where == '"dateNotification" IS NOT NULL'
|
|
|
|
|
|
def test_multiple_filters_joined_by_and():
|
|
where, params, _ = build_where(
|
|
[("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA
|
|
)
|
|
assert where == '"uid" = ? AND "annee" >= ?'
|
|
assert params == ["a", 2020]
|
|
|
|
|
|
def test_unknown_column_raises():
|
|
with pytest.raises(FilterError) as exc:
|
|
build_where([("foo__exact", "bar")], SCHEMA)
|
|
assert "foo" in str(exc.value)
|
|
assert exc.value.field == "foo__exact"
|
|
|
|
|
|
def test_unknown_operator_raises():
|
|
with pytest.raises(FilterError) as exc:
|
|
build_where([("uid__weird", "x")], SCHEMA)
|
|
assert "weird" in str(exc.value)
|
|
|
|
|
|
def test_bad_int_value_raises():
|
|
with pytest.raises(FilterError):
|
|
build_where([("annee__exact", "notanint")], SCHEMA)
|
|
|
|
|
|
def test_bad_date_value_raises():
|
|
with pytest.raises(FilterError):
|
|
build_where([("dateNotification__exact", "notadate")], SCHEMA)
|
|
|
|
|
|
def test_date_iso_coercion():
|
|
where, params, _ = build_where(
|
|
[("dateNotification__greater", "2024-01-01")], SCHEMA
|
|
)
|
|
from datetime import date
|
|
assert params == [date(2024, 1, 1)]
|
|
|
|
|
|
def test_reserved_params_are_ignored():
|
|
where, params, order = build_where(
|
|
[
|
|
("page", "2"),
|
|
("page_size", "100"),
|
|
("columns", "uid"),
|
|
("count", "false"),
|
|
("uid__exact", "z"),
|
|
],
|
|
SCHEMA,
|
|
)
|
|
assert where == '"uid" = ?'
|
|
assert params == ["z"]
|
|
|
|
|
|
def test_sort_returns_order_by():
|
|
where, params, order = build_where(
|
|
[("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA
|
|
)
|
|
assert where == "TRUE"
|
|
assert order == '"annee" DESC, "uid" ASC'
|
|
|
|
|
|
def test_sort_invalid_direction_raises():
|
|
with pytest.raises(FilterError):
|
|
build_where([("uid__sort", "sideways")], SCHEMA)
|
|
|
|
|
|
def test_param_without_operator_raises():
|
|
with pytest.raises(FilterError):
|
|
build_where([("uidexact", "x")], SCHEMA)
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_filters.py -v`
|
|
Expected: FAIL avec `ImportError`.
|
|
|
|
- [ ] **Step 3: Implémenter `filters.py`**
|
|
|
|
Créer `src/api/filters.py` :
|
|
|
|
```python
|
|
from datetime import date, datetime
|
|
|
|
import polars as pl
|
|
|
|
OPERATORS = {
|
|
"exact",
|
|
"contains",
|
|
"notcontains",
|
|
"less",
|
|
"greater",
|
|
"strictly_less",
|
|
"strictly_greater",
|
|
"in",
|
|
"notin",
|
|
"isnull",
|
|
"isnotnull",
|
|
"sort",
|
|
}
|
|
|
|
RESERVED_PARAMS = {"page", "page_size", "columns", "count"}
|
|
|
|
|
|
class FilterError(ValueError):
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(message)
|
|
self.field = field
|
|
|
|
|
|
def _coerce(value: str, dtype: pl.DataType, key: str):
|
|
if dtype == pl.String:
|
|
return value
|
|
if dtype.is_integer():
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
raise FilterError(
|
|
f"Valeur entière attendue, reçu {value!r}", field=key
|
|
)
|
|
if dtype.is_float():
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
raise FilterError(
|
|
f"Valeur décimale attendue, reçu {value!r}", field=key
|
|
)
|
|
if dtype == pl.Date:
|
|
try:
|
|
return date.fromisoformat(value)
|
|
except ValueError:
|
|
raise FilterError(
|
|
f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key
|
|
)
|
|
if dtype == pl.Datetime:
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
raise FilterError(
|
|
f"Datetime ISO 8601 attendu, reçu {value!r}", field=key
|
|
)
|
|
return value
|
|
|
|
|
|
def _split_key(key: str) -> tuple[str, str] | None:
|
|
if "__" not in key:
|
|
return None
|
|
col, _, op = key.rpartition("__")
|
|
if not col or not op:
|
|
return None
|
|
return col, op
|
|
|
|
|
|
def build_where(
|
|
args: list[tuple[str, str]], schema: pl.Schema
|
|
) -> tuple[str, list, str | None]:
|
|
"""Parse query params into (where_sql, params, order_by_sql).
|
|
|
|
args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict
|
|
preserves insertion order on `request.args.items(multi=True)`).
|
|
"""
|
|
where_parts: list[str] = []
|
|
params: list = []
|
|
order_parts: list[str] = []
|
|
|
|
for key, value in args:
|
|
if key in RESERVED_PARAMS:
|
|
continue
|
|
|
|
parsed = _split_key(key)
|
|
if not parsed:
|
|
raise FilterError(f"Paramètre non reconnu : {key}", field=key)
|
|
col, op = parsed
|
|
|
|
if op not in OPERATORS:
|
|
raise FilterError(f"Opérateur inconnu : __{op}", field=key)
|
|
|
|
if col not in schema:
|
|
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
|
|
|
|
if op == "sort":
|
|
direction = value.lower()
|
|
if direction not in ("asc", "desc"):
|
|
raise FilterError(
|
|
f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key
|
|
)
|
|
order_parts.append(f'"{col}" {direction.upper()}')
|
|
continue
|
|
|
|
if op in ("isnull", "isnotnull"):
|
|
sql = "IS NULL" if op == "isnull" else "IS NOT NULL"
|
|
where_parts.append(f'"{col}" {sql}')
|
|
continue
|
|
|
|
dtype = schema[col]
|
|
|
|
if op in ("in", "notin"):
|
|
values = [_coerce(v.strip(), dtype, key) for v in value.split(",")]
|
|
placeholders = ",".join(["?"] * len(values))
|
|
sql_op = "IN" if op == "in" else "NOT IN"
|
|
where_parts.append(f'"{col}" {sql_op} ({placeholders})')
|
|
params.extend(values)
|
|
continue
|
|
|
|
v = _coerce(value, dtype, key)
|
|
|
|
op_sql = {
|
|
"exact": "=",
|
|
"less": "<=",
|
|
"greater": ">=",
|
|
"strictly_less": "<",
|
|
"strictly_greater": ">",
|
|
}
|
|
if op in op_sql:
|
|
where_parts.append(f'"{col}" {op_sql[op]} ?')
|
|
params.append(v)
|
|
elif op == "contains":
|
|
where_parts.append(f'"{col}" LIKE ?')
|
|
params.append(f"%{v}%")
|
|
elif op == "notcontains":
|
|
where_parts.append(f'"{col}" NOT LIKE ?')
|
|
params.append(f"%{v}%")
|
|
|
|
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
|
|
order_sql = ", ".join(order_parts) if order_parts else None
|
|
return where_sql, params, order_sql
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_filters.py -v`
|
|
Expected: PASS (~20 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/filters.py tests/api/test_filters.py
|
|
rtk git add src/api/filters.py tests/api/test_filters.py
|
|
rtk git commit -m "API: parser de filtres et génération SQL paramétré (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Décorateur `@require_token`
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/api/auth.py`
|
|
- Create: `tests/api/test_auth.py`
|
|
|
|
- [ ] **Step 1: Écrire les tests d'auth**
|
|
|
|
Créer `tests/api/test_auth.py` :
|
|
|
|
```python
|
|
from flask import Flask, g, jsonify
|
|
|
|
from src.api import tokens_db
|
|
from src.api.auth import require_token
|
|
|
|
|
|
def _make_app():
|
|
app = Flask(__name__)
|
|
|
|
@app.route("/protected")
|
|
@require_token
|
|
def protected():
|
|
return jsonify({"token_id": g.token_id})
|
|
|
|
return app
|
|
|
|
|
|
def test_missing_header_returns_401(temp_db):
|
|
app = _make_app()
|
|
resp = app.test_client().get("/protected")
|
|
assert resp.status_code == 401
|
|
assert resp.get_json()["message"] == "missing_token"
|
|
|
|
|
|
def test_bearer_without_value_returns_401(temp_db):
|
|
app = _make_app()
|
|
resp = app.test_client().get(
|
|
"/protected", headers={"Authorization": "Bearer "}
|
|
)
|
|
assert resp.status_code == 401
|
|
assert resp.get_json()["message"] == "missing_token"
|
|
|
|
|
|
def test_invalid_token_returns_401(temp_db):
|
|
app = _make_app()
|
|
resp = app.test_client().get(
|
|
"/protected", headers={"Authorization": "Bearer decpinfo_unknown"}
|
|
)
|
|
assert resp.status_code == 401
|
|
assert resp.get_json()["message"] == "invalid_token"
|
|
|
|
|
|
def test_revoked_token_returns_401(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
|
tokens_db.revoke_token(temp_db, token_id)
|
|
app = _make_app()
|
|
resp = app.test_client().get(
|
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert resp.status_code == 401
|
|
assert resp.get_json()["message"] == "revoked_token"
|
|
|
|
|
|
def test_valid_token_sets_g_and_calls_view(temp_db):
|
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
|
app = _make_app()
|
|
resp = app.test_client().get(
|
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["token_id"] == token_id
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_auth.py -v`
|
|
Expected: FAIL avec `ImportError` sur `src.api.auth`.
|
|
|
|
- [ ] **Step 3: Implémenter `auth.py`**
|
|
|
|
Créer `src/api/auth.py` :
|
|
|
|
```python
|
|
import os
|
|
from functools import wraps
|
|
|
|
from flask import abort, g, jsonify, make_response, request
|
|
|
|
from src.api import tokens_db
|
|
|
|
|
|
def _abort_401(message: str):
|
|
resp = make_response(jsonify({"message": message}), 401)
|
|
abort(resp)
|
|
|
|
|
|
def require_token(fn):
|
|
@wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
header = request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
_abort_401("missing_token")
|
|
token = header[len("Bearer ") :].strip()
|
|
if not token:
|
|
_abort_401("missing_token")
|
|
db_path = os.environ["USERS_DB_PATH"]
|
|
row = tokens_db.get_token_by_plaintext(db_path, token)
|
|
if row is None:
|
|
_abort_401("invalid_token")
|
|
if row["revoked_at"] is not None:
|
|
_abort_401("revoked_token")
|
|
g.token_id = row["id"]
|
|
return fn(*args, **kwargs)
|
|
|
|
return wrapper
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_auth.py -v`
|
|
Expected: PASS (5 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/auth.py tests/api/test_auth.py
|
|
rtk git add src/api/auth.py tests/api/test_auth.py
|
|
rtk git commit -m "API: décorateur @require_token (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Squelette de blueprint + `/health`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/api/__init__.py`
|
|
- Create: `src/api/routes.py`
|
|
- Create: `tests/api/test_health.py`
|
|
|
|
- [ ] **Step 1: Écrire le test `/health`**
|
|
|
|
Créer `tests/api/test_health.py` :
|
|
|
|
```python
|
|
from flask import Flask
|
|
|
|
from src.api import init_api
|
|
|
|
|
|
def _make_app():
|
|
app = Flask(__name__)
|
|
init_api(app)
|
|
return app
|
|
|
|
|
|
def test_health_returns_ok_without_auth():
|
|
app = _make_app()
|
|
resp = app.test_client().get("/api/v1/health")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json() == {"status": "ok"}
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_health.py -v`
|
|
Expected: FAIL (`init_api` n'existe pas).
|
|
|
|
- [ ] **Step 3: Implémenter `__init__.py` avec blueprint flask-smorest**
|
|
|
|
Remplacer le contenu de `src/api/__init__.py` :
|
|
|
|
```python
|
|
from flask_smorest import Api, Blueprint
|
|
|
|
from src.api import routes
|
|
|
|
|
|
def init_api(server) -> None:
|
|
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
|
|
server.config.setdefault("API_TITLE", "decp.info API")
|
|
server.config.setdefault("API_VERSION", "v1")
|
|
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
|
|
server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1")
|
|
server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json")
|
|
server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger")
|
|
server.config.setdefault(
|
|
"OPENAPI_SWAGGER_UI_URL",
|
|
"https://cdn.jsdelivr.net/npm/swagger-ui-dist/",
|
|
)
|
|
|
|
api = Api(server)
|
|
api.register_blueprint(routes.bp)
|
|
```
|
|
|
|
- [ ] **Step 4: Implémenter `routes.py` avec uniquement `/health`**
|
|
|
|
Créer `src/api/routes.py` :
|
|
|
|
```python
|
|
from flask_smorest import Blueprint
|
|
|
|
bp = Blueprint(
|
|
"api_v1",
|
|
"api_v1",
|
|
url_prefix="/api/v1",
|
|
description="API privée decp.info — accès tabulaire aux marchés publics.",
|
|
)
|
|
|
|
|
|
@bp.route("/health")
|
|
def health():
|
|
"""Sonde de santé, sans authentification."""
|
|
return {"status": "ok"}
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_health.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/__init__.py src/api/routes.py tests/api/test_health.py
|
|
rtk git add src/api/__init__.py src/api/routes.py tests/api/test_health.py
|
|
rtk git commit -m "API: blueprint flask-smorest + endpoint /health (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Brancher l'API dans l'app Dash
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/app.py`
|
|
|
|
- [ ] **Step 1: Écrire un test d'intégration**
|
|
|
|
Ajouter à la fin de `tests/api/test_health.py` :
|
|
|
|
```python
|
|
def test_health_via_real_app():
|
|
"""Vérifie que init_api est bien branché dans src.app."""
|
|
from src.app import app as dash_app
|
|
|
|
resp = dash_app.server.test_client().get("/api/v1/health")
|
|
assert resp.status_code == 200
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_health.py::test_health_via_real_app -v`
|
|
Expected: FAIL (404 — l'API n'est pas branchée).
|
|
|
|
- [ ] **Step 3: Brancher `init_api` dans `src/app.py`**
|
|
|
|
Dans `src/app.py`, après la ligne `cache.init_app(...)` (vers la ligne 52, juste avant la définition de `@app.server.route("/robots.txt")`), ajouter :
|
|
|
|
```python
|
|
from src.api import init_api
|
|
|
|
init_api(app.server)
|
|
```
|
|
|
|
L'import en haut du fichier serait plus propre mais romprait l'ordre des dépendances (l'API a besoin que `src.db.conn` soit prêt). Le faire ici garantit l'ordre.
|
|
|
|
- [ ] **Step 4: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_health.py -v`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 5: Vérifier que l'app Dash démarre toujours**
|
|
|
|
Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -5`
|
|
Expected: collection sans erreur.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/app.py tests/api/test_health.py
|
|
rtk git add src/app.py tests/api/test_health.py
|
|
rtk git commit -m "API: branchement init_api dans l'app Dash (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Endpoint `/schema`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/api/routes.py`
|
|
- Create: `tests/api/test_endpoints_schema.py`
|
|
|
|
- [ ] **Step 1: Étendre `tests/api/conftest.py` avec une fixture token "vivant"**
|
|
|
|
Ajouter à `tests/api/conftest.py` (à la fin) :
|
|
|
|
```python
|
|
@pytest.fixture
|
|
def api_client(monkeypatch, tmp_path):
|
|
"""Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté."""
|
|
db_path = tmp_path / "users.test.sqlite"
|
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
|
from flask import Flask
|
|
|
|
from src.api import init_api, tokens_db
|
|
|
|
tokens_db.init_schema(db_path)
|
|
server = Flask(__name__)
|
|
init_api(server)
|
|
return server.test_client(), db_path
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_token_header(api_client):
|
|
from src.api import tokens_db
|
|
|
|
_, db_path = api_client
|
|
token, _ = tokens_db.create_token(db_path, "test-token")
|
|
return {"Authorization": f"Bearer {token}"}
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire les tests de `/schema`**
|
|
|
|
Créer `tests/api/test_endpoints_schema.py` :
|
|
|
|
```python
|
|
def test_schema_without_token_returns_401(api_client):
|
|
client, _ = api_client
|
|
resp = client.get("/api/v1/schema")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_schema_returns_columns(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
resp = client.get("/api/v1/schema", headers=valid_token_header)
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert "columns" in data
|
|
assert isinstance(data["columns"], list)
|
|
assert len(data["columns"]) > 0
|
|
first = data["columns"][0]
|
|
assert set(first.keys()) >= {"name", "type"}
|
|
# uid doit être présent dans le schéma DECP de test
|
|
names = [c["name"] for c in data["columns"]]
|
|
assert "uid" in names
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_endpoints_schema.py -v`
|
|
Expected: FAIL (route /schema absente → 404).
|
|
|
|
- [ ] **Step 4: Implémenter `/schema` dans `routes.py`**
|
|
|
|
Ajouter à la fin de `src/api/routes.py` :
|
|
|
|
```python
|
|
from src.api.auth import require_token
|
|
from src.db import schema as duckdb_schema
|
|
|
|
|
|
@bp.route("/schema")
|
|
@require_token
|
|
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}
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_endpoints_schema.py -v`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py
|
|
rtk git add src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py
|
|
rtk git commit -m "API: endpoint /schema (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Endpoint `/data` — pagination de base sans filtres
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/api/routes.py`
|
|
- Create: `tests/api/test_endpoints_data.py`
|
|
|
|
- [ ] **Step 1: Écrire les premiers tests `/data`**
|
|
|
|
Créer `tests/api/test_endpoints_data.py` :
|
|
|
|
```python
|
|
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"]
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
|
|
Expected: FAIL (404 sur /data).
|
|
|
|
- [ ] **Step 3: Ajouter la route `/data` dans `routes.py`**
|
|
|
|
Ajouter à `src/api/routes.py` (après `/schema`) :
|
|
|
|
```python
|
|
from flask import request
|
|
from flask_smorest import abort
|
|
|
|
from src.api.filters import FilterError, build_where
|
|
from src.db import count_marches, query_marches, schema as duckdb_schema
|
|
|
|
|
|
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("/data")
|
|
@require_token
|
|
def data():
|
|
"""Endpoint tabulaire : filtres dynamiques sur les colonnes DECP."""
|
|
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
|
|
import polars as pl
|
|
import polars.selectors as cs
|
|
|
|
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),
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
|
|
Expected: PASS (6 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/routes.py tests/api/test_endpoints_data.py
|
|
rtk git add src/api/routes.py tests/api/test_endpoints_data.py
|
|
rtk git commit -m "API: endpoint /data avec pagination et filtres (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Endpoint `/data` — filtres, tri, columns (intégration)
|
|
|
|
**Files:**
|
|
|
|
- Modify: `tests/api/test_endpoints_data.py`
|
|
|
|
- [ ] **Step 1: Ajouter les tests d'intégration filtres + tri + columns**
|
|
|
|
Ajouter à `tests/api/test_endpoints_data.py` :
|
|
|
|
```python
|
|
def test_data_filter_exact_string(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
# On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne
|
|
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
|
|
assert base["data"], "test.parquet vide ?"
|
|
uid = base["data"][0]["uid"]
|
|
|
|
resp = client.get(
|
|
f"/api/v1/data?uid__exact={uid}", headers=valid_token_header
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
assert all(row["uid"] == uid for row in body["data"])
|
|
|
|
|
|
def test_data_unknown_column_filter_returns_400(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
resp = client.get(
|
|
"/api/v1/data?colonne_inexistante__exact=x",
|
|
headers=valid_token_header,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_data_columns_selection(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
resp = client.get(
|
|
"/api/v1/data?columns=uid,objet&page_size=3",
|
|
headers=valid_token_header,
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
for row in body["data"]:
|
|
assert set(row.keys()) == {"uid", "objet"}
|
|
|
|
|
|
def test_data_columns_unknown_returns_400(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
resp = client.get(
|
|
"/api/v1/data?columns=uid,foobar",
|
|
headers=valid_token_header,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_data_sort_desc(api_client, valid_token_header):
|
|
client, _ = api_client
|
|
resp = client.get(
|
|
"/api/v1/data?dateNotification__sort=desc&page_size=5",
|
|
headers=valid_token_header,
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
dates = [row["dateNotification"] for row in body["data"] if row.get("dateNotification")]
|
|
assert dates == sorted(dates, reverse=True)
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests**
|
|
|
|
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
|
|
Expected: PASS (5 nouveaux tests, 11 au total). Si des FAIL surgissent, c'est généralement lié à l'absence d'une colonne dans `tests/test.parquet` — adapter la valeur de test à ce qui existe.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
rtk git add tests/api/test_endpoints_data.py
|
|
rtk git commit -m "API: tests d'intégration filtres/tri/columns (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Suivi de consommation — compteur SQLite asynchrone
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/api/tracking.py`
|
|
- Modify: `src/api/routes.py`
|
|
- Create: `tests/api/test_tracking.py`
|
|
|
|
- [ ] **Step 1: Écrire le test du worker compteur**
|
|
|
|
Créer `tests/api/test_tracking.py` :
|
|
|
|
```python
|
|
import time
|
|
|
|
from src.api import tokens_db, tracking
|
|
|
|
|
|
def test_counter_worker_increments_count(temp_db):
|
|
_, token_id = tokens_db.create_token(temp_db, "x")
|
|
|
|
tracking.start_worker(str(temp_db))
|
|
try:
|
|
tracking.enqueue_counter_update(token_id)
|
|
tracking.enqueue_counter_update(token_id)
|
|
# Laisser le worker drainer la queue
|
|
tracking.flush(timeout=2.0)
|
|
finally:
|
|
tracking.stop_worker()
|
|
|
|
rows = tokens_db.list_tokens(temp_db)
|
|
assert rows[0]["count_total"] == 2
|
|
assert rows[0]["last_used_at"] is not None
|
|
|
|
|
|
def test_after_request_hook_increments_counter_async(
|
|
api_client, valid_token_header
|
|
):
|
|
client, db_path = api_client
|
|
# Récupérer le token_id du token créé par la fixture
|
|
from src.api import tokens_db, tracking
|
|
|
|
rows = tokens_db.list_tokens(db_path)
|
|
token_id = rows[0]["id"]
|
|
|
|
# Faire une requête (qui doit déclencher l'incrément)
|
|
client.get("/api/v1/health") # pas authentifiée → ne compte pas
|
|
client.get("/api/v1/schema", headers=valid_token_header)
|
|
|
|
tracking.flush(timeout=2.0)
|
|
|
|
rows = tokens_db.list_tokens(db_path)
|
|
assert rows[0]["count_total"] == 1
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_tracking.py -v`
|
|
Expected: FAIL (`src.api.tracking` n'existe pas).
|
|
|
|
- [ ] **Step 3: Implémenter `tracking.py` (compteur seulement, Matomo arrivera Task 12)**
|
|
|
|
Créer `src/api/tracking.py` :
|
|
|
|
```python
|
|
import queue
|
|
import threading
|
|
from typing import Optional
|
|
|
|
from src.api import tokens_db
|
|
from src.utils import logger
|
|
|
|
_STOP_SENTINEL = object()
|
|
_queue: Optional[queue.Queue] = None
|
|
_worker_thread: Optional[threading.Thread] = None
|
|
_db_path: Optional[str] = None
|
|
|
|
|
|
def _worker_loop(q: queue.Queue, db_path: str) -> None:
|
|
while True:
|
|
item = q.get()
|
|
try:
|
|
if item is _STOP_SENTINEL:
|
|
return
|
|
kind, payload = item
|
|
if kind == "counter":
|
|
token_id = payload
|
|
try:
|
|
tokens_db.increment_usage(db_path, token_id)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning(
|
|
"tracking: échec increment_usage token_id=%s",
|
|
token_id,
|
|
exc_info=True,
|
|
)
|
|
finally:
|
|
q.task_done()
|
|
|
|
|
|
def start_worker(db_path: str) -> None:
|
|
global _queue, _worker_thread, _db_path
|
|
if _worker_thread is not None and _worker_thread.is_alive():
|
|
return
|
|
_db_path = db_path
|
|
_queue = queue.Queue()
|
|
_worker_thread = threading.Thread(
|
|
target=_worker_loop, args=(_queue, db_path), daemon=True
|
|
)
|
|
_worker_thread.start()
|
|
|
|
|
|
def stop_worker() -> None:
|
|
global _worker_thread, _queue
|
|
if _worker_thread is None:
|
|
return
|
|
_queue.put(_STOP_SENTINEL)
|
|
_worker_thread.join(timeout=2.0)
|
|
_worker_thread = None
|
|
_queue = None
|
|
|
|
|
|
def enqueue_counter_update(token_id: int) -> None:
|
|
if _queue is None:
|
|
return # tracking désactivé (tests par ex.)
|
|
_queue.put(("counter", token_id))
|
|
|
|
|
|
def flush(timeout: float = 2.0) -> None:
|
|
"""Attend que la queue soit drainée. Utile en test."""
|
|
if _queue is None:
|
|
return
|
|
_queue.join()
|
|
```
|
|
|
|
- [ ] **Step 4: Brancher le hook `after_request` dans `routes.py`**
|
|
|
|
Ajouter à `src/api/routes.py` (avant la définition de `bp.route("/data")`, mais après l'init de `bp`) :
|
|
|
|
```python
|
|
from flask import g
|
|
|
|
from src.api import tracking
|
|
|
|
|
|
@bp.after_request
|
|
def _track_consumption(response):
|
|
token_id = getattr(g, "token_id", None)
|
|
if token_id is not None:
|
|
tracking.enqueue_counter_update(token_id)
|
|
return response
|
|
```
|
|
|
|
- [ ] **Step 5: Démarrer le worker au moment du `init_api`**
|
|
|
|
Modifier `src/api/__init__.py` pour ajouter dans `init_api` (après `api.register_blueprint(routes.bp)`) :
|
|
|
|
```python
|
|
import os
|
|
|
|
from src.api import tracking
|
|
|
|
tracking.start_worker(os.environ["USERS_DB_PATH"])
|
|
```
|
|
|
|
- [ ] **Step 6: Adapter la fixture `api_client` pour démarrer/arrêter le worker proprement**
|
|
|
|
Modifier `tests/api/conftest.py`, fixture `api_client` :
|
|
|
|
```python
|
|
@pytest.fixture
|
|
def api_client(monkeypatch, tmp_path):
|
|
db_path = tmp_path / "users.test.sqlite"
|
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
|
from flask import Flask
|
|
|
|
from src.api import init_api, tokens_db, tracking
|
|
|
|
tokens_db.init_schema(db_path)
|
|
server = Flask(__name__)
|
|
init_api(server)
|
|
yield server.test_client(), db_path
|
|
tracking.stop_worker()
|
|
```
|
|
|
|
- [ ] **Step 7: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_tracking.py tests/api/test_endpoints_schema.py tests/api/test_endpoints_data.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py
|
|
rtk git add src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py
|
|
rtk git commit -m "API: compteur de consommation SQLite asynchrone (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Suivi de consommation — événements Matomo (fire-and-forget)
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/api/tracking.py`
|
|
- Modify: `src/api/routes.py`
|
|
- Modify: `tests/api/test_tracking.py`
|
|
|
|
- [ ] **Step 1: Écrire les tests Matomo (avec mock httpx)**
|
|
|
|
Ajouter à `tests/api/test_tracking.py` :
|
|
|
|
```python
|
|
def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header):
|
|
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false")
|
|
client, _ = api_client
|
|
called = []
|
|
|
|
from src.api import tracking
|
|
|
|
monkeypatch.setattr(
|
|
tracking,
|
|
"_post_matomo",
|
|
lambda **kw: called.append(kw),
|
|
)
|
|
client.get("/api/v1/health")
|
|
tracking.flush(timeout=2.0)
|
|
assert called == []
|
|
|
|
|
|
def test_matomo_enabled_posts_event(
|
|
monkeypatch, api_client, valid_token_header
|
|
):
|
|
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true")
|
|
monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php")
|
|
monkeypatch.setenv("MATOMO_SITE_ID", "42")
|
|
captured = []
|
|
|
|
from src.api import tracking
|
|
|
|
monkeypatch.setattr(
|
|
tracking,
|
|
"_post_matomo",
|
|
lambda **kw: captured.append(kw),
|
|
)
|
|
|
|
client, _ = api_client
|
|
client.get("/api/v1/schema", headers=valid_token_header)
|
|
tracking.flush(timeout=2.0)
|
|
|
|
assert len(captured) == 1
|
|
call = captured[0]
|
|
assert call["params"]["idsite"] == "42"
|
|
assert call["params"]["rec"] == "1"
|
|
assert "token-" in call["params"]["uid"]
|
|
assert call["params"]["dimension2"] == "200"
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests, confirmer FAIL**
|
|
|
|
Run: `rtk pytest tests/api/test_tracking.py -v -k matomo`
|
|
Expected: FAIL (`_post_matomo` n'existe pas).
|
|
|
|
- [ ] **Step 3: Étendre `tracking.py` avec l'envoi Matomo**
|
|
|
|
Ajouter à `src/api/tracking.py` (en haut, après les imports existants) :
|
|
|
|
```python
|
|
import os
|
|
import httpx
|
|
```
|
|
|
|
Étendre `_worker_loop` pour gérer un nouveau type `"matomo"` :
|
|
|
|
```python
|
|
def _worker_loop(q: queue.Queue, db_path: str) -> None:
|
|
while True:
|
|
item = q.get()
|
|
try:
|
|
if item is _STOP_SENTINEL:
|
|
return
|
|
kind, payload = item
|
|
if kind == "counter":
|
|
token_id = payload
|
|
try:
|
|
tokens_db.increment_usage(db_path, token_id)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning(
|
|
"tracking: échec increment_usage token_id=%s",
|
|
token_id,
|
|
exc_info=True,
|
|
)
|
|
elif kind == "matomo":
|
|
try:
|
|
_post_matomo(**payload)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning(
|
|
"tracking: échec envoi Matomo", exc_info=True
|
|
)
|
|
finally:
|
|
q.task_done()
|
|
|
|
|
|
def _post_matomo(url: str, params: dict) -> None:
|
|
"""POST fire-and-forget vers la Tracking API Matomo. Mockable en test."""
|
|
httpx.post(url, data=params, timeout=5.0)
|
|
|
|
|
|
def enqueue_matomo_event(
|
|
token_id: int,
|
|
path: str,
|
|
query_string: str,
|
|
status_code: int,
|
|
user_agent: str,
|
|
) -> None:
|
|
if _queue is None:
|
|
return
|
|
if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true":
|
|
return
|
|
url = os.getenv("MATOMO_URL")
|
|
site_id = os.getenv("MATOMO_SITE_ID")
|
|
if not url or not site_id:
|
|
return
|
|
full_url = f"https://decp.info{path}"
|
|
if query_string:
|
|
full_url += f"?{query_string}"
|
|
params = {
|
|
"idsite": site_id,
|
|
"rec": "1",
|
|
"url": full_url,
|
|
"action_name": f"API {path}",
|
|
"uid": f"token-{token_id}",
|
|
"dimension1": str(token_id),
|
|
"dimension2": str(status_code),
|
|
"ua": user_agent,
|
|
}
|
|
_queue.put(("matomo", {"url": url, "params": params}))
|
|
```
|
|
|
|
- [ ] **Step 4: Compléter le hook `after_request` pour envoyer aussi à Matomo**
|
|
|
|
Modifier `src/api/routes.py`, la fonction `_track_consumption` :
|
|
|
|
```python
|
|
@bp.after_request
|
|
def _track_consumption(response):
|
|
token_id = getattr(g, "token_id", None)
|
|
if token_id is not None:
|
|
tracking.enqueue_counter_update(token_id)
|
|
tracking.enqueue_matomo_event(
|
|
token_id=token_id,
|
|
path=request.path,
|
|
query_string=request.query_string.decode("utf-8", errors="replace"),
|
|
status_code=response.status_code,
|
|
user_agent=request.headers.get("User-Agent", ""),
|
|
)
|
|
return response
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer les tests, confirmer PASS**
|
|
|
|
Run: `rtk pytest tests/api/test_tracking.py -v`
|
|
Expected: PASS (tous tests dont les 2 nouveaux Matomo).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/tracking.py src/api/routes.py tests/api/test_tracking.py
|
|
rtk git add src/api/tracking.py src/api/routes.py tests/api/test_tracking.py
|
|
rtk git commit -m "API: envoi Matomo fire-and-forget en async (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Documentation OpenAPI explicite + Changelog
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/api/routes.py`
|
|
- Modify: `CHANGELOG.md`
|
|
|
|
- [ ] **Step 1: Ajouter docstrings et statuts OpenAPI sur les routes**
|
|
|
|
Modifier les routes dans `src/api/routes.py` pour enrichir Swagger. Exemple sur `/data` (ajouter en docstring) :
|
|
|
|
```python
|
|
@bp.route("/data")
|
|
@require_token
|
|
def data():
|
|
"""Récupère des marchés publics filtrés.
|
|
|
|
Filtres en query string sous la forme `<colonne>__<opérateur>=<valeur>`.
|
|
|
|
Opérateurs : exact, contains, notcontains, less, greater,
|
|
strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort.
|
|
|
|
Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000),
|
|
columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)).
|
|
"""
|
|
... # corps inchangé
|
|
```
|
|
|
|
Faire de même pour `/schema` et `/health` (une ligne suffit).
|
|
|
|
- [ ] **Step 2: Vérifier manuellement la doc Swagger**
|
|
|
|
Run: `rtk python run.py &` puis ouvrir <http://127.0.0.1:8050/api/v1/swagger> dans un navigateur.
|
|
Expected: Swagger UI affiche les 3 endpoints, doc lisible.
|
|
|
|
Arrêter le serveur (Ctrl-C ou `kill %1`).
|
|
|
|
- [ ] **Step 3: Ajouter l'entrée Changelog**
|
|
|
|
Lire `CHANGELOG.md` pour comprendre le format puis ajouter en haut, sous la section "Unreleased" (ou créer cette section) :
|
|
|
|
```markdown
|
|
## [Unreleased]
|
|
|
|
### Ajouté
|
|
|
|
- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri).
|
|
- Endpoint `/api/v1/schema` (description du dataset).
|
|
- Endpoint `/api/v1/health` (sonde monitoring).
|
|
- Documentation interactive Swagger UI à `/api/v1/swagger`.
|
|
- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke).
|
|
- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async.
|
|
|
|
### Configuration
|
|
|
|
- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`.
|
|
- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`.
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer toute la suite de tests**
|
|
|
|
Run: `rtk pytest tests/api/ -v`
|
|
Expected: tous les tests PASS.
|
|
|
|
Run: `rtk pytest tests/ -x` (suite complète, y compris l'app Dash)
|
|
Expected: pas de régression (la suite Dash existante peut être longue, c'est normal).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/api/routes.py CHANGELOG.md
|
|
rtk git add src/api/routes.py CHANGELOG.md
|
|
rtk git commit -m "API: documentation OpenAPI et CHANGELOG (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Mention de l'API sur la page À propos
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/pages/a_propos.py` (vérifier le nom exact)
|
|
|
|
- [ ] **Step 1: Repérer la page À propos**
|
|
|
|
Run: `rtk ls src/pages/`
|
|
Identifier le fichier correspondant à `/a-propos` (probablement `a_propos.py` ou `apropos.py`).
|
|
|
|
- [ ] **Step 2: Ajouter une section "API"**
|
|
|
|
Dans le layout de la page, ajouter une section markdown ou un bloc HTML :
|
|
|
|
```python
|
|
dcc.Markdown("""
|
|
### API privée
|
|
|
|
Une API HTTP est disponible pour accéder aux mêmes données par programme.
|
|
Documentation interactive : [Swagger UI](/api/v1/swagger).
|
|
|
|
L'accès se fait sur token. Pour en obtenir un, contactez
|
|
[colin@maudry.com](mailto:colin@maudry.com).
|
|
"""),
|
|
```
|
|
|
|
- [ ] **Step 3: Vérifier visuellement**
|
|
|
|
Run: `rtk python run.py &`
|
|
Ouvrir <http://127.0.0.1:8050/a-propos> et confirmer que la section apparaît.
|
|
Arrêter le serveur.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
rtk pre-commit run --files src/pages/a_propos.py
|
|
rtk git add src/pages/a_propos.py
|
|
rtk git commit -m "API: mention sur la page À propos (#78)"
|
|
```
|
|
|
|
---
|
|
|
|
## Notes pour l'exécution
|
|
|
|
- **Couverture spec** : chaque section du spec a une ou plusieurs tâches associées (deps → Task 1, tokens_db → Task 2, CLI → Task 3, filters → Task 4, auth → Task 5, blueprint+health → Task 6, branchement → Task 7, schema → Task 8, data → Tasks 9-10, tracking SQLite → Task 11, Matomo → Task 12, OpenAPI+changelog → Task 13).
|
|
- **`tests/test.parquet`** : peut ne pas contenir toutes les colonnes citées dans les tests (cf. CLAUDE.md). Si un test échoue parce qu'une colonne n'existe pas dans la fixture, l'adapter à une colonne effectivement présente.
|
|
- **`src/db.py:127`** ouvre une connexion DuckDB read-only au moment de l'import. Cela peut être lent au premier lancement de la suite de tests (rebuild de la base). C'est normal.
|
|
- **Worker tracking** : démarré dans `init_api`, démon. Pas de cleanup nécessaire en prod (mourra avec le process). En test, la fixture `api_client` appelle `stop_worker()` pour propreté.
|
|
- **Git flow** : on est déjà sur `feature/78_api`. Tous les commits y vont. Pas de `git push` (cf. CLAUDE.md).
|