diff --git a/docs/superpowers/specs/2026-05-13-api-privee-design.md b/docs/superpowers/specs/2026-05-13-api-privee-design.md new file mode 100644 index 0000000..0a838c3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-api-privee-design.md @@ -0,0 +1,428 @@ +# API privée decp.info — Design + +**Date** : 2026-05-13 +**Statut** : design validé, en attente du plan d'implémentation + +## 1. Contexte et objectifs + +decp.info reçoit des demandes récurrentes pour un accès programmatique aux +données DECP exposées par l'application web. Le besoin est d'ouvrir une API +HTTP **privée** (accès sur token), inspirée de l'API tabulaire de data.gouv.fr +(https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/swagger/), +qu'un utilisateur en cours s'est déjà appropriée comme référence. + +Objectifs explicites : + +- Réponses rapides. +- API documentée (OpenAPI + Swagger UI). +- Suivi de la consommation par utilisateur. + +Non-objectifs (V1) : + +- Self-service de création de tokens via UI web. +- Rate-limiting / quotas. +- Formats de sortie autres que JSON (CSV, Parquet…). +- Endpoints sémantiques métier (`/acheteurs/{id}`, etc.). + +## 2. Choix structurants + +### 2.1 Framework : Flask + flask-smorest + +L'API est ajoutée à l'application Flask existante (serveur Dash) sous forme +d'un blueprint flask-smorest monté sur `/api/v1`. Choix motivé par : + +- L'app Dash actuelle tourne déjà sur Flask via gunicorn. +- DuckDB est ouvert une seule fois au boot dans `src/db.py` (`conn` read-only) + et peut être partagé directement par les endpoints API. +- L'API est tabulaire avec filtres **dynamiques** : la liste des colonnes et + des types vient du schéma DuckDB, pas d'une déclaration Pydantic. Les + bénéfices de FastAPI (auto-validation Pydantic) sont donc faibles. +- flask-smorest génère OpenAPI + sert Swagger UI nativement. +- Un seul process, un seul serveur, un seul déploiement. + +Alternatives écartées : + +- **FastAPI séparé reverse-proxié** : deux processus, ops plus complexe, + bénéfice marginal vu les filtres dynamiques. +- **FastAPI englobant Flask via WSGIMiddleware** : changerait le serveur de + toute l'app Dash existante, migration risquée. + +### 2.2 Style d'API : tabulaire générique + +Un endpoint unique de requête (`/api/v1/data`) avec filtres dynamiques sur +toutes les colonnes du schéma, à l'image du swagger cible. Aucun endpoint +sémantique métier en V1. + +### 2.3 Authentification : tokens admin manuels + +Tokens Bearer émis manuellement par l'admin via un CLI. Pas de page web de +gestion en V1. Modèle prévu pour se lier ultérieurement aux comptes +utilisateurs (cf. `comptes_utilisateurs.md`) sans migration de données. + +### 2.4 Suivi de consommation : Matomo asynchrone + compteurs locaux + +- Matomo en fire-and-forget pour l'analyse fine (qui, quand, quoi, code HTTP). +- Compteurs locaux SQLite (`count_total`, `last_used_at`) pour identifier + les tokens inactifs et préparer un éventuel rate-limit futur. + +## 3. Architecture + +### 3.1 Arborescence + +``` +src/api/ +├── __init__.py # init_api(server) — enregistre le blueprint flask-smorest +├── routes.py # endpoints /data, /schema, /health +├── schemas.py # marshmallow : query params, réponses +├── filters.py # parsing & validation `col__op=val` → (where_sql, params) +├── auth.py # décorateur @require_token, header Authorization Bearer +├── tracking.py # worker thread compteurs SQLite + httpx fire-and-forget Matomo +├── tokens_db.py # CRUD api_tokens dans users.sqlite +└── tokens_cli.py # python -m src.api.tokens_cli create|list|revoke +``` + +`src/auth/` reste réservé aux comptes utilisateurs interactifs +(`comptes_utilisateurs.md`), distincts des tokens API. + +### 3.2 Branchement + +Dans `src/app.py`, après l'init Dash : + +```python +from src.api import init_api +init_api(app.server) +``` + +`init_api` enregistre le blueprint sur `/api/v1` et expose : + +- `/api/v1/data` +- `/api/v1/schema` +- `/api/v1/health` +- `/api/v1/swagger` (UI) +- `/api/v1/openapi.json` + +### 3.3 Partage de la connexion DuckDB + +Les routes importent `src.db.conn` et utilisent les helpers existants +(`query_marches`, `count_marches`) ainsi que `src.db.schema` (Polars Schema) +pour la whitelist de colonnes. + +## 4. Stockage + +### 4.1 SQLite consolidée + +Une seule base SQLite, `users.sqlite` à la racine, contient : + +- `users` (futur — cf. `comptes_utilisateurs.md`) +- `api_tokens` (V1) + +Bénéfice : un seul fichier à sauvegarder et migrer ; la liaison future +`api_tokens.user_id → users.id` est immédiate sans migration de données. + +### 4.2 Schéma `api_tokens` + +```sql +CREATE TABLE 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 idx_api_tokens_hash ON api_tokens(token_hash); +``` + +`user_id` est `NULL` pour les tokens admin manuels. Quand le self-service +arrivera, il suffira de le renseigner. + +## 5. Endpoints + +### 5.1 Vue d'ensemble + +| Méthode | Path | Auth | Rôle | +| ------- | ---------------------- | ------ | ------------------------------------------- | +| GET | `/api/v1/data` | Bearer | Endpoint tabulaire principal | +| GET | `/api/v1/schema` | Bearer | Liste des colonnes (nom, type, description) | +| GET | `/api/v1/health` | Aucune | Sonde monitoring | +| GET | `/api/v1/swagger` | Aucune | Swagger UI | +| GET | `/api/v1/openapi.json` | Aucune | Spec OpenAPI | + +### 5.2 `/api/v1/data` — langage de requête + +Filtres en query string, opérateurs suffixés par `__` (mirror swagger cible) : + +| Opérateur | Sens | +| -------------------- | ----------------------------------------------------- | +| `__exact` | égalité | +| `__contains` | sous-chaîne (LIKE %v%) | +| `__notcontains` | négation de `__contains` | +| `__less` | ≤ | +| `__greater` | ≥ | +| `__strictly_less` | < | +| `__strictly_greater` | > | +| `__in` | liste séparée par virgules | +| `__notin` | négation de `__in` | +| `__isnull` | `IS NULL` (valeur ignorée) | +| `__isnotnull` | `IS NOT NULL` (valeur ignorée) | +| `__sort` | `asc` ou `desc` — ordre = ordre des params dans l'URL | + +Autres paramètres réservés : + +- `page` (int, défaut 1, ≥1) +- `page_size` (int, défaut 50, max 1000) +- `columns` (string, liste séparée par virgules ; défaut = toutes) +- `count` (bool, défaut `true` ; `false` → `meta.total` absent, économise un `COUNT(*)`) + +Exemple : + +``` +GET /api/v1/data?acheteur_departement_code__exact=44 + &dateNotification__greater=2024-01-01 + &montant__strictly_greater=100000 + &objet__contains=informatique + &cpv_8__in=72000000,72200000 + &dateNotification__sort=desc + &page=1 + &page_size=50 + &columns=uid,objet,montant,dateNotification +``` + +### 5.3 Sécurité du parsing + +`filters.py` est l'unique chemin de génération du `WHERE` SQL : + +1. Chaque clé `__` est splittée puis validée : + - `` doit être dans `src.db.schema` (whitelist stricte). + - `` doit être dans la liste blanche d'opérateurs. + - La valeur est convertie selon le type Polars de la colonne : + - `String` : utilisée telle quelle. + - `Int*` : `int(value)`, 400 si non parseable. + - `Float*` : `float(value)`, 400 si non parseable. + - `Date` / `Datetime` : ISO 8601 (`YYYY-MM-DD` ou `YYYY-MM-DDTHH:MM:SS`), 400 sinon. + - Booléens : **les colonnes booléennes sont stockées comme strings + "oui"/"non" en DuckDB** (cf. `src/db.py:43`), donc traitées comme + `String`. L'utilisateur filtre avec `colonne__exact=oui`. +2. Le `WHERE` est composé de fragments paramétrés (`?`) ; les valeurs + utilisateur sont passées au moteur DuckDB via les paramètres, **jamais + concaténées** dans le SQL. +3. Le résultat est consommé par `src.db.query_marches(where_sql=..., params=...)` + qui existe déjà. + +### 5.4 Format de réponse + +```json +{ + "data": [{ "uid": "...", "objet": "...", "montant": 12345.0 }], + "meta": { "page": 1, "page_size": 50, "total": 1234 }, + "links": { + "next": "/api/v1/data?...&page=2", + "prev": null + } +} +``` + +`meta.total` est omis si `count=false`. `links.next`/`links.prev` sont +`null` aux extrémités. + +### 5.5 `/api/v1/schema` + +```json +{ + "columns": [ + { "name": "uid", "type": "string", "description": "..." }, + { "name": "montant", "type": "float", "description": "..." } + ] +} +``` + +Descriptions tirées de `../decp-processing/reference/base_schema.json` si +disponible ; sinon vides. + +### 5.6 V1 : JSON only + +Pas de CSV / Parquet. Ajout possible plus tard via `?format=`. + +## 6. Authentification + +### 6.1 Transmission + +Header HTTP standard : + +``` +Authorization: Bearer dcpinfo_a1b2c3d4... +``` + +Pas de support via query string (fuites dans les logs). + +### 6.2 Format du token + +Préfixe `dcpinfo_` + 32 octets aléatoires hex (43 caractères au total). +Le préfixe facilite la détection de fuites (gitleaks, etc.). + +### 6.3 Hashing + +`sha256(token)` stocké dans `api_tokens.token_hash`. Pas de bcrypt/argon2 : +les tokens ont 256 bits d'entropie, le brute-force est impossible et un +hash lent ralentirait inutilement chaque requête API. + +### 6.4 Décorateur `@require_token` + +1. Lit `Authorization` ; absent → 401 `missing_token`. +2. Calcule `sha256`, `SELECT` indexé. +3. Pas trouvé → 401 `invalid_token`. +4. `revoked_at IS NOT NULL` → 401 `revoked_token`. +5. Pose `flask.g.token_id` pour `tracking.py`. + +### 6.5 CLI de gestion + +`python -m src.api.tokens_cli` : + +``` +create --label "Marie Dupont - étude transport 2026" + → affiche UNE FOIS le token plaintext (irrécupérable ensuite) + +list + → id | label | created_at | last_used_at | count_total | revoked? + +revoke + → set revoked_at = now() (ISO 8601 UTC) +``` + +Pas d'UI web pour les tokens en V1. + +## 7. Suivi de consommation + +### 7.1 Hook + +`@bp.after_request` déclenche deux actions **sans bloquer la réponse** : + +1. Enfilage d'un update SQLite dans une `queue.Queue` consommée par un + worker thread unique (writer série, pas de contention SQLite). +2. POST httpx fire-and-forget vers la Tracking API Matomo. + +Les erreurs des deux chemins sont loggées en `warning` mais jamais propagées +à l'utilisateur. + +### 7.2 Update SQLite + +```sql +UPDATE api_tokens +SET count_total = count_total + 1, + last_used_at = ? +WHERE id = ? +``` + +### 7.3 Event Matomo + +``` +POST https://analytics.maudry.com/matomo.php + idsite=14 + rec=1 + url=https://decp.info/api/v1/data? + action_name=API /data + uid=token- # jamais le token plaintext + dimension1= + dimension2= + ua= +``` + +Custom Dimensions à créer côté Matomo : `dimension1=token_id`, +`dimension2=http_status`. + +### 7.4 Variables d'environnement nouvelles + +``` +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 +MATOMO_TRACKING_ENABLED=true # false en dev/test par défaut +USERS_DB_PATH=./users.sqlite # tests : tests/users.test.sqlite +``` + +## 8. Erreurs + +Format uniforme (RFC 7807, déjà standard flask-smorest) : + +```json +{ + "code": 400, + "status": "Bad Request", + "message": "Colonne inconnue 'foo'.", + "errors": { "field": "foo__exact" } +} +``` + +| HTTP | Cas | +| ---- | --------------------------------------------------------------------- | +| 200 | Succès | +| 400 | Colonne/opérateur/valeur invalide, `page_size` hors bornes | +| 401 | `missing_token` / `invalid_token` / `revoked_token` | +| 404 | Path API inexistant | +| 500 | Exception non gérée — message générique, stack trace loggée seulement | + +Pas de 429 en V1. + +Les 4xx sont loggées en `info` (path + token_id), les 500 en `error` avec +stack trace. + +## 9. Tests + +Tests pytest purs (pas de Selenium) via `app.server.test_client()`. + +``` +tests/api/ +├── test_filters.py # parsing, génération SQL/params, erreurs +├── test_auth.py # 401 cases, last_used_at update +├── test_tokens_cli.py # create/list/revoke +├── test_endpoints_data.py # pagination, filtres, sort, columns, count=false +├── test_endpoints_schema.py # /schema renvoie les colonnes attendues +├── test_health.py # /health 200 sans auth +└── test_tracking.py # compteurs SQLite, Matomo désactivé par défaut + mock httpx +``` + +Fixtures pytest : + +- `api_client` : `app.server.test_client()` +- `valid_token_header` : crée un token dans `tests/users.test.sqlite`, renvoie le header `Authorization: Bearer …` +- `revoked_token_header` : idem avec `revoked_at` set + +Ajouts `pyproject.toml` `[tool.pytest.ini_options].env` : + +``` +USERS_DB_PATH=tests/users.test.sqlite +MATOMO_TRACKING_ENABLED=false +``` + +Couverture cible : 100% de `filters.py` et `auth.py` (sécurité-critique) ; +raisonnable ailleurs. + +## 10. Dépendances nouvelles + +À ajouter dans `pyproject.toml` : + +- `flask-smorest` (blueprint + OpenAPI + Swagger UI) +- `marshmallow` (déjà transitif de flask-smorest, à expliciter) + +`httpx` est déjà présent. Pas d'autres dépendances. + +## 11. Documentation utilisateur + +À fournir séparément (hors scope spec, à inclure dans le plan d'implémentation) : + +- Section "API" dans la page À propos ou page dédiée `/api` avec : + - lien vers Swagger UI + - exemples curl + - procédure pour obtenir un token (« contactez X ») +- Mention dans le `CHANGELOG.md` à la sortie de version. + +## 12. Risques et points ouverts + +- **Coût du `COUNT(*)`** sur gros filtres : mitigé par `count=false` opt-out. +- **Charge SQLite write** : un worker série suffira pour le trafic attendu + (admin tokens manuels, faible volume). Si le volume monte, passer à un + buffer en RAM avec flush périodique. +- **Matomo down** : impact nul sur l'API (fire-and-forget loggué). +- **Évolution vers self-service** : déjà préparée par `user_id` nullable et + séparation `src/api/` vs `src/auth/`.