Add comprehensive test suite for image processing and related services
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
2026-02-24 11:22:10 -05:00
commit cc99fea20a
80 changed files with 9582 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
# Prompt Claude Code — Phase 1 : Fondations sécurité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE1.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en FastAPI, SQLAlchemy async et sécurité des APIs REST. Tu travailles sur le projet Imago, un backend centralisé de gestion d'images conçu pour servir plusieurs applications clientes simultanément.
</role>
<project_context>
## Projet : Imago
Backend FastAPI existant servant de hub centralisé pour :
- Stocker et gérer des images avec génération automatique de thumbnails
- Extraire les métadonnées EXIF (appareil photo, GPS, paramètres de prise de vue)
- Effectuer l'OCR sur les images via Tesseract
- Analyser les images avec Claude Vision AI (description + classification par tags)
### Stack technique en place
- FastAPI + Uvicorn (serveur ASGI)
- SQLAlchemy async + Alembic (ORM + migrations)
- Pydantic v2 + pydantic-settings (validation + config)
- Pillow + piexif (traitement images + EXIF)
- pytesseract (OCR)
- Anthropic Claude via httpx (Vision AI)
- aiofiles (I/O async)
- SQLite (développement) / PostgreSQL (production)
### Structure du projet
```
imago/
├── app/
│ ├── main.py # Application FastAPI
│ ├── config.py # Settings depuis .env
│ ├── database.py # Engine SQLAlchemy async + session
│ ├── models/
│ │ └── image.py # Modèle Image (EXIF, OCR, AI, statut)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic
│ ├── routers/
│ │ ├── images.py # Endpoints images (CRUD + pipeline)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── storage.py # Sauvegarde fichiers + thumbnails
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude
│ ├── scraper.py # Scraping web
│ └── pipeline.py # Orchestration pipeline AI
├── tests/
│ └── test_services.py
├── requirements.txt
└── .env
```
### Problème actuel
Le backend est **entièrement public** : aucune authentification, aucune isolation entre clients. N'importe quelle application peut accéder, modifier ou supprimer toutes les données. C'est incompatible avec un hub multi-clients en production.
</project_context>
<mission>
## Mission : implémenter la Phase 1 — Fondations sécurité
Tu dois réaliser les 4 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
### Livrable 1.1 — Authentification API Keys + JWT avec scopes (priorité CRITIQUE)
**Objectif** : sécuriser tous les endpoints avec deux mécanismes d'authentification complémentaires.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Nouveau modèle SQLAlchemy `APIClient` :
- `id` : UUID, primary key
- `name` : String, nom de l'application cliente (ex: "Shaarli", "App Mobile")
- `api_key_hash` : String, hash SHA-256 de la clé API (jamais stocker en clair)
- `scopes` : JSON, liste des permissions accordées
- `plan` : Enum (`free`, `standard`, `premium`)
- `is_active` : Boolean, default True
- `created_at` / `updated_at` : DateTime
2. `app/dependencies/__init__.py` + `app/dependencies/auth.py` — Dépendances FastAPI :
- `verify_api_key(authorization: str = Header(...))` → retourne l'`APIClient` authentifié
- `require_scope(scope: str)` → factory qui vérifie qu'un scope est accordé
- `get_current_client` → alias réutilisable dans tous les routers
- Lever `HTTP 401` si clé invalide ou client inactif
- Lever `HTTP 403` si scope manquant
3. `app/routers/auth.py` — Endpoints de gestion des clients :
- `POST /auth/clients` — Créer un nouveau client (retourne la clé en clair une seule fois)
- `GET /auth/clients` — Lister les clients (admin only, scope `admin`)
- `GET /auth/clients/{id}` — Détail d'un client
- `PATCH /auth/clients/{id}` — Modifier un client (scopes, plan, is_active)
- `POST /auth/clients/{id}/rotate-key` — Régénérer la clé API
- `DELETE /auth/clients/{id}` — Désactiver (soft delete)
4. Scopes à définir :
- `images:read` — lire les images et métadonnées
- `images:write` — uploader et modifier des images
- `images:delete` — supprimer des images
- `ai:use` — utiliser les endpoints AI (résumé URL, génération tâches)
- `admin` — gestion des clients (réservé à un super-client)
5. `app/config.py` — Ajouter :
- `ADMIN_API_KEY` : clé du super-client admin (depuis .env)
- `JWT_SECRET_KEY` et `JWT_ALGORITHM` pour les tokens JWT futurs
**Contraintes** :
- Les clés API doivent être générées avec `secrets.token_urlsafe(32)`
- Le hash doit être SHA-256 via `hashlib`
- La clé en clair ne doit **jamais** être stockée en base ni apparaître dans les logs
- Utiliser `python-jose` et `passlib` (ajouter au requirements.txt)
---
### Livrable 1.2 — Modèle clients + isolation complète des données (priorité CRITIQUE)
**Objectif** : rendre toutes les données strictement isolées par client.
**Ce qui doit être créé ou modifié** :
1. `app/models/image.py` — Modifier le modèle `Image` existant :
- Ajouter `client_id = Column(UUID, ForeignKey("api_clients.id"), nullable=False, index=True)`
- Ajouter la relation `client = relationship("APIClient", back_populates="images")`
2. `app/models/client.py` — Ajouter la relation inverse :
- `images = relationship("Image", back_populates="client", cascade="all, delete-orphan")`
3. `app/services/storage.py` — Modifier le service de stockage :
- Les fichiers doivent être stockés dans `uploads/{client_id}/{filename}`
- Les thumbnails dans `thumbnails/{client_id}/{filename}`
- La fonction `save_upload` doit accepter `client_id` en paramètre
4. `app/routers/images.py` — Modifier TOUS les endpoints :
- Injecter `client: APIClient = Depends(get_current_client)` dans chaque endpoint
- Appliquer `require_scope("images:read")` sur les GET
- Appliquer `require_scope("images:write")` sur les POST
- Appliquer `require_scope("images:delete")` sur les DELETE
- Filtrer systématiquement toutes les requêtes avec `WHERE image.client_id = client.id`
- **Un client ne doit jamais pouvoir accéder aux images d'un autre client**
5. `app/routers/ai.py` — Même injection + scope `ai:use`
6. `alembic/versions/` — Créer une migration Alembic pour :
- Créer la table `api_clients`
- Ajouter la colonne `client_id` à la table `images`
- Créer un client "default" avec toutes les permissions pour la migration des données existantes
**Contraintes** :
- Toute requête DB touchant des images DOIT inclure le filtre `client_id`
- Écrire une fonction utilitaire `get_image_or_404(image_id, client_id, db)` qui centralise ce pattern
- Le filtre doit être appliqué au niveau service, pas seulement dans les routers
---
### Livrable 1.3 — Rate limiting par client et par endpoint (priorité HAUTE)
**Objectif** : protéger le hub contre les abus et contrôler la consommation par client.
**Ce qui doit être créé ou modifié** :
1. Ajouter `slowapi` au requirements.txt
2. `app/middleware/rate_limit.py` — Configuration du rate limiting :
- Identifier les requêtes par `client_id` (extrait du token) plutôt que par IP
- Limites différentes selon le plan :
- `free` : 20 uploads/heure, 50 requêtes AI/heure
- `standard` : 100 uploads/heure, 200 requêtes AI/heure
- `premium` : 500 uploads/heure, 1000 requêtes AI/heure
- Retourner `HTTP 429` avec header `Retry-After` si limite atteinte
3. `app/routers/images.py` — Appliquer les décorateurs rate limit sur :
- `POST /images/upload` → limiter par plan
- `POST /images/{id}/reprocess` → limiter par plan
4. `app/routers/ai.py` — Appliquer sur :
- `POST /ai/summarize` → limiter par plan
- `POST /ai/draft-task` → limiter par plan
5. `app/config.py` — Ajouter les variables de configuration des limites par plan
**Contraintes** :
- Les compteurs de rate limit doivent être par client (pas global)
- Inclure les headers standard : `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
---
### Livrable 1.4 — Tests d'intégration auth + multi-tenants (priorité HAUTE)
**Objectif** : valider que la sécurité fonctionne correctement avec une suite de tests exhaustive.
**Ce qui doit être créé** :
1. `tests/conftest.py` — Fixtures pytest :
- `test_db` : base SQLite in-memory pour les tests
- `client_a` et `client_b` : deux clients API de test avec des clés distinctes
- `auth_headers_a` et `auth_headers_b` : headers d'authentification correspondants
- `async_client` : `httpx.AsyncClient` configuré pour les tests
2. `tests/test_auth.py` — Tests d'authentification :
- ✅ Requête sans clé → HTTP 401
- ✅ Requête avec clé invalide → HTTP 401
- ✅ Requête avec clé valide → HTTP 200
- ✅ Client désactivé → HTTP 401
- ✅ Scope manquant → HTTP 403
- ✅ Rotation de clé → ancienne clé invalide, nouvelle clé valide
- ✅ Création de client → clé retournée une seule fois
3. `tests/test_isolation.py` — Tests d'isolation multi-tenants :
- ✅ Client A upload une image → invisible pour Client B
- ✅ Client B ne peut pas lire l'image du Client A (même avec bonne clé)
- ✅ Client B ne peut pas supprimer l'image du Client A → HTTP 404
- ✅ Listing des images de A ne retourne que les images de A
- ✅ Les fichiers sont stockés dans des répertoires séparés
- ✅ Reprocess d'une image appartenant à un autre client → HTTP 404
4. `tests/test_rate_limit.py` — Tests de rate limiting :
- ✅ Dépassement de quota → HTTP 429 avec header `Retry-After`
- ✅ Compteur distinct par client
- ✅ Headers `X-RateLimit-*` présents sur chaque réponse
**Contraintes** :
- Tous les tests doivent être async (`pytest-asyncio`)
- Les appels à l'API Anthropic doivent être mockés dans les tests
- Couverture minimum : 85% sur les modules `auth.py`, `dependencies/auth.py`, `models/client.py`
</mission>
<execution_rules>
## Règles d'exécution
### Ordre de réalisation
Implémenter dans l'ordre strict : 1.1 → 1.2 → 1.3 → 1.4. Ne pas passer au livrable suivant sans avoir validé le précédent avec `pytest`.
### À chaque livrable
1. Lire les fichiers existants concernés avant de les modifier
2. Écrire le code complet (pas de `# TODO` ni de `# ...`)
3. Mettre à jour `requirements.txt` si de nouvelles dépendances sont ajoutées
4. Créer la migration Alembic si le modèle de données change
5. Lancer `pytest tests/ -v` et corriger les erreurs avant de continuer
### Qualité du code
- Typage complet sur toutes les fonctions (mypy compatible)
- Docstrings sur tous les services et dépendances
- Pas de secrets hardcodés — tout passe par `app/config.py` + `.env`
- Logging structuré avec le niveau approprié (pas de `print()`)
- Gestion explicite de toutes les exceptions (pas de `except Exception: pass`)
### Sécurité — règles absolues
- Les clés API ne doivent **jamais** apparaître dans les logs, réponses d'erreur, ou stack traces
- Le hash doit être SHA-256 avec sel (utiliser `hashlib.pbkdf2_hmac` ou `passlib`)
- Les messages d'erreur d'authentification doivent être génériques (ne pas indiquer si la clé existe ou si c'est le scope qui manque — sauf pour HTTP 403 vs 401)
- Valider les entrées avec Pydantic sur tous les endpoints de création/modification
### Migrations Alembic
- Une migration par changement de schéma
- Inclure les `upgrade()` et `downgrade()` complets
- Tester la migration sur une base vierge avant de la considérer terminée
### Résultat attendu
À la fin de la Phase 1, la commande suivante doit réussir sans erreur :
```bash
pytest tests/ -v --cov=app --cov-report=term-missing
```
Et le serveur doit démarrer sans erreur avec :
```bash
python run.py
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| 1.1 | `app/models/client.py`, `app/dependencies/auth.py`, `app/routers/auth.py`, `app/config.py`, `requirements.txt` | `pytest tests/test_auth.py` |
| 1.2 | `app/models/image.py`, `app/services/storage.py`, `app/routers/images.py`, `app/routers/ai.py`, `alembic/versions/xxx_add_clients.py` | `pytest tests/test_isolation.py` |
| 1.3 | `app/middleware/rate_limit.py`, `app/routers/images.py`, `app/routers/ai.py`, `app/config.py` | `pytest tests/test_rate_limit.py` |
| 1.4 | `tests/conftest.py`, `tests/test_auth.py`, `tests/test_isolation.py`, `tests/test_rate_limit.py` | `pytest tests/ -v --cov=app` |
**Résultat final de la Phase 1 :** hub sécurisé, données strictement isolées par client, déployable en production.
</deliverable_summary>
+765
View File
@@ -0,0 +1,765 @@
# Prompt Claude Code — Phase 2 : Robustesse et scalabilité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE2_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en systèmes distribués, FastAPI async et infrastructure de production. Tu travailles sur le projet Imago, un hub centralisé de gestion d'images servant plusieurs applications clientes simultanément.
La Phase 1 (authentification, isolation multi-tenants, rate limiting, tests d'intégration) est entièrement terminée et validée. Tu prends le relais pour rendre ce hub robuste, scalable et observable en production.
</role>
<project_context>
## État du projet après Phase 1
### Stack en place (Phase 1 complète)
- **FastAPI + Uvicorn** — framework web async
- **SQLAlchemy async + Alembic** — ORM + migrations
- **Pydantic v2 + pydantic-settings** — validation + config
- **python-jose + passlib** — JWT + hachage API Keys
- **slowapi** — rate limiting par client
- **Pillow + piexif** — traitement images + EXIF
- **pytesseract** — OCR Tesseract
- **Anthropic Claude via httpx** — Vision AI
- **aiofiles** — I/O async
- **pytest-asyncio + httpx** — tests d'intégration
### Structure complète du projet (après Phase 1)
```
imago/
├── app/
│ ├── main.py # Application FastAPI, lifespan, middlewares
│ ├── config.py # Settings depuis .env (inclut ADMIN_API_KEY, JWT_SECRET)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── models/
│ │ ├── __init__.py
│ │ ├── image.py # Image (avec client_id FK, EXIF, OCR, AI, statut)
│ │ └── client.py # APIClient (id, name, api_key_hash, scopes, plan, quotas)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ └── rate_limit.py # Rate limiting par plan client (slowapi)
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── auth.py # CRUD clients, rotation de clé
│ │ ├── images.py # Endpoints images (filtrés par client_id)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── __init__.py
│ ├── storage.py # Stockage fichiers (uploads/{client_id}/{filename})
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude + summarize_url + draft_task
│ ├── scraper.py # Scraping web BeautifulSoup
│ └── pipeline.py # Pipeline EXIF → OCR → AI (via BackgroundTasks)
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers
│ ├── test_services.py # Tests unitaires services
│ ├── test_auth.py # Tests authentification
│ ├── test_isolation.py # Tests isolation multi-tenants
│ └── test_rate_limit.py # Tests rate limiting
├── alembic/
│ └── versions/ # Migrations : table images + table api_clients
├── requirements.txt
├── .env
├── Dockerfile
└── docker-compose.yml
```
### Ce qui reste problématique (cible de la Phase 2)
**Pipeline non persistant** : les `BackgroundTasks` FastAPI perdent toutes les tâches en file si le serveur redémarre. Aucun retry en cas d'échec de l'API Anthropic. Aucune priorité entre clients `free` et `premium`. Concurrence non contrôlée.
**Stockage non abstrait** : fichiers sur disque local uniquement, couplé aux chemins absolus. Impossible de scaler, aucune réplication, URLs statiques exposent les chemins réels.
**Observabilité absente** : tous les logs sont des `print()`. Aucune métrique applicative. Health check basique qui retourne toujours "ok". Impossible de monitorer en production.
**CI/CD inexistant** : pas d'automatisation des tests, pas de pipeline de déploiement, pas de contrôles qualité automatisés.
</project_context>
<mission>
## Mission : implémenter la Phase 2 — Robustesse et scalabilité
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 2.1 — Migration BackgroundTasks → ARQ + Redis (priorité HAUTE)
**Objectif** : remplacer le pipeline fragile par un système de file de tâches persistant avec retry automatique, priorités par plan client et concurrence contrôlée.
**Nouvelles dépendances à ajouter dans `requirements.txt`** :
```
arq==0.25.0
redis==5.0.8
```
**Ce qui doit être créé ou modifié** :
1. `app/config.py` — Ajouter les variables Redis et worker :
```python
REDIS_URL: str = "redis://localhost:6379"
WORKER_MAX_JOBS: int = 10 # concurrence max globale
WORKER_JOB_TIMEOUT: int = 180 # secondes avant timeout forcé
WORKER_MAX_TRIES: int = 3 # tentatives avant dead-letter
AI_STEP_TIMEOUT: int = 120 # timeout spécifique appel Vision AI
OCR_STEP_TIMEOUT: int = 30 # timeout spécifique OCR
```
2. `app/workers/__init__.py` + `app/workers/image_worker.py` — Worker ARQ :
- Fonction `process_image_task(ctx, image_id: int, client_id: str)` qui appelle `pipeline.py`
- `WorkerSettings` avec :
- `functions = [process_image_task]`
- `redis_settings` depuis `settings.REDIS_URL`
- `max_jobs = settings.WORKER_MAX_JOBS`
- `job_timeout = settings.WORKER_JOB_TIMEOUT`
- `retry_jobs = True`
- `max_tries = settings.WORKER_MAX_TRIES`
- `on_job_start`, `on_job_end`, `on_job_abort` — hooks de logging
- Backoff exponentiel : délais `[1, 4, 16]` secondes entre les tentatives
- Dead-letter : après `max_tries` échecs, marquer l'image `status=error` + log `ERROR`
3. `app/workers/redis_client.py` — Client Redis partagé :
- Pool de connexions async (`aioredis` ou `redis.asyncio`)
- Fonction `get_redis_pool()` injectable comme dépendance FastAPI
- Gestion propre de la connexion dans le lifespan de `main.py`
4. `app/services/pipeline.py` — Ajouter publication d'événements Redis :
- À chaque étape complétée, publier sur le channel `pipeline:{image_id}` :
```python
await redis.publish(f"pipeline:{image_id}", json.dumps({
"event": "step.completed",
"step": "exif",
"duration_ms": elapsed,
"data": { ... } # résumé des données extraites
}))
```
- Événements à publier : `pipeline.started`, `step.completed` (×3), `pipeline.done`, `pipeline.error`
5. `app/routers/images.py` — Modifier `POST /images/upload` :
- Remplacer `background_tasks.add_task(process_image_pipeline, ...)` par :
```python
queue_name = "premium" if client.plan == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name
)
```
6. `app/main.py` — Modifier le `lifespan` :
- Créer et stocker le pool ARQ au démarrage : `app.state.arq_pool`
- Créer et stocker le pool Redis au démarrage : `app.state.redis`
- Fermer proprement les deux à l'arrêt
7. `worker.py` — Script de démarrage du worker (à la racine du projet) :
```python
# Lancer avec : python worker.py
import asyncio
from arq import run_worker
from app.workers.image_worker import WorkerSettings
if __name__ == "__main__":
asyncio.run(run_worker(WorkerSettings))
```
8. `docker-compose.yml` — Ajouter le service Redis et le worker :
```yaml
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes # persistance AOF
worker:
build: .
command: python worker.py
depends_on: [backend, redis]
env_file: .env
```
**Contraintes** :
- Le pool ARQ doit être créé une seule fois au démarrage, pas à chaque requête
- Les tâches doivent survivre à un redémarrage du serveur FastAPI (elles restent dans Redis)
- Un job qui dépasse `job_timeout` doit être marqué `error` en base, pas silencieusement ignoré
- Les files `premium` et `standard` doivent être des queues ARQ distinctes (pas juste un label)
---
### Livrable 2.2 — Abstraction StorageBackend + support MinIO/S3 (priorité HAUTE)
**Objectif** : découpler le code du stockage physique pour permettre une migration transparente vers S3/MinIO sans modifier les routers ni les services métier.
**Nouvelle dépendance** :
```
aioboto3==13.0.0
```
**Ce qui doit être créé ou modifié** :
1. `app/services/storage_backend.py` — Interface abstraite + deux implémentations :
**Classe de base** :
```python
class StorageBackend(ABC):
@abstractmethod
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier. Retourne le chemin stocké."""
@abstractmethod
async def delete(self, path: str) -> None:
"""Supprime un fichier."""
@abstractmethod
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Retourne une URL d'accès temporaire signée."""
@abstractmethod
async def exists(self, path: str) -> bool:
"""Vérifie qu'un fichier existe."""
@abstractmethod
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
```
**`LocalStorage(StorageBackend)`** :
- `save()` : écrit dans `{UPLOAD_DIR}/{path}` avec `aiofiles`, crée les dossiers parents si besoin
- `delete()` : supprime le fichier, ignore si absent
- `get_signed_url()` : génère un token HMAC-SHA256 signé avec `itsdangerous.URLSafeTimedSerializer` incluant `path` + expiration. Retourne `/files/signed/{token}`
- `exists()` : `Path(full_path).exists()`
- `get_size()` : `Path(full_path).stat().st_size`
**`S3Storage(StorageBackend)`** :
- Constructeur : `bucket`, `prefix`, `session` (`aioboto3.Session`)
- `save()` : `put_object` avec le `content_type` correct
- `delete()` : `delete_object`
- `get_signed_url()` : `generate_presigned_url("get_object", ExpiresIn=expires_in)`
- `exists()` : `head_object` → True/False
- `get_size()` : `head_object` → `ContentLength`
- Compatible S3, MinIO et Cloudflare R2 (même API boto3)
2. `app/services/storage.py` — Refactoring complet :
- Supprimer le code de stockage direct
- La fonction `save_upload(file, client_id)` utilise maintenant le backend injecté
- La fonction `delete_files(paths)` utilise le backend
- Exposer `get_storage_backend() -> StorageBackend` : factory qui lit `settings.STORAGE_BACKEND` et instancie le bon backend
3. `app/config.py` — Ajouter :
```python
STORAGE_BACKEND: str = "local" # "local" | "s3"
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago" # préfixe dans le bucket
SIGNED_URL_SECRET: str = "changez-moi" # secret HMAC pour LocalStorage
```
4. `app/routers/images.py` — Ajouter les endpoints d'URLs signées :
- `GET /images/{id}/download-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- `GET /images/{id}/thumbnail-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- Vérifier que l'image appartient au client avant de générer l'URL
- Appliquer `require_scope("images:read")`
5. `app/routers/files.py` — Nouveau router pour le stockage local signé :
- `GET /files/signed/{token}` — valide le token HMAC, retourne le fichier via `FileResponse`
- Lever `HTTP 403` si token invalide ou expiré
- Lever `HTTP 410 Gone` si le fichier n'existe plus sur disque
- Ce router n'est monté que si `STORAGE_BACKEND == "local"`
6. `app/main.py` — Supprimer les `StaticFiles` mounts `/static/*` et les remplacer par le router `files.py`
**Contraintes** :
- Le reste du code (routers, pipeline) ne doit **jamais** importer `LocalStorage` ou `S3Storage` directement — uniquement `StorageBackend` et la factory
- Les tokens HMAC doivent avoir une durée de vie stricte (vérification côté serveur à la lecture)
- Le backend S3 doit fonctionner avec MinIO en local (via `S3_ENDPOINT_URL`)
---
### Livrable 2.3 — URLs signées : quota et tracking de l'espace disque (priorité HAUTE)
**Objectif** : enrichir le système d'URLs signées avec le suivi de la consommation de stockage par client pour permettre l'application des quotas.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Ajouter la méthode de calcul de quota :
```python
async def get_storage_used_bytes(self, db: AsyncSession) -> int:
"""Somme de file_size pour toutes les images actives du client."""
result = await db.execute(
select(func.sum(Image.file_size))
.where(Image.client_id == self.id)
)
return result.scalar_one() or 0
```
2. `app/services/storage.py` — Dans `save_upload()` :
- Avant de sauvegarder, vérifier que `storage_used + file_size <= quota_storage_mb * 1024 * 1024`
- Lever `HTTP 413` avec message clair si quota dépassé : `"Quota de stockage atteint (X MB / Y MB)"`
- Vérifier aussi `count(images) < quota_images` avant l'upload
3. `app/routers/images.py` — Ajouter dans `GET /images` la consommation actuelle dans la réponse :
```json
{
"total": 42,
"storage_used_mb": 128.5,
"storage_quota_mb": 500,
"quota_pct": 25.7
}
```
4. `app/schemas/__init__.py` — Mettre à jour `PaginatedImages` avec les champs de quota
**Contraintes** :
- La vérification de quota doit être atomique (SELECT + INSERT dans la même transaction si possible)
- Ne pas recalculer la somme à chaque requête GET — utiliser une colonne `storage_used_bytes` dénormalisée sur `APIClient`, mise à jour lors des uploads et suppressions
---
### Livrable 2.4 — Logs structurés + métriques Prometheus (priorité MOYENNE)
**Objectif** : remplacer tous les `print()` par des logs JSON structurés et exposer des métriques Prometheus exploitables pour le monitoring de production.
**Nouvelles dépendances** :
```
structlog==24.4.0
prometheus-fastapi-instrumentator==0.14.0
```
**Ce qui doit être créé ou modifié** :
1. `app/logging_config.py` — Configuration structlog centralisée :
```python
import structlog
def configure_logging(debug: bool = False):
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer() if debug
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.DEBUG if debug else logging.INFO
),
)
```
2. Remplacer **chaque `print()`** dans le projet par le logger structlog approprié :
Dans `app/services/pipeline.py` :
```python
log = structlog.get_logger()
# Remplacer print(f"[Pipeline:{image_id}] Étape 1/3 — EXIF") par :
log.info("pipeline.step.started", image_id=image_id, step="exif")
# Remplacer print(f"[Pipeline:{image_id}] EXIF OK") par :
log.info("pipeline.step.completed", image_id=image_id, step="exif",
duration_ms=elapsed, camera=image.exif_make)
# En cas d'erreur :
log.error("pipeline.step.failed", image_id=image_id, step="exif",
error=str(e), exc_info=True)
```
Dans `app/services/storage.py`, `exif_service.py`, `ocr_service.py`, `ai_vision.py` : même principe.
Dans `app/workers/image_worker.py` : logs sur `on_job_start`, `on_job_end`, `on_job_abort`.
3. `app/middleware/logging_middleware.py` — Middleware HTTP de logs :
- Logger chaque requête : `method`, `path`, `status_code`, `duration_ms`, `client_id` (si authentifié)
- Exclure `/health` et `/metrics` pour éviter le bruit
- Format : `log.info("http.request", method="POST", path="/api/v1/images/upload", status=201, duration_ms=45, client_id="abc")`
4. `app/main.py` — Intégration Prometheus :
```python
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=True,
excluded_handlers=["/health", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
```
5. `app/metrics.py` — Métriques custom applicatives :
```python
from prometheus_client import Counter, Histogram, Gauge
images_uploaded = Counter(
"hub_images_uploaded_total",
"Total uploads par client et par plan",
["client_id", "plan"]
)
pipeline_duration = Histogram(
"hub_pipeline_duration_seconds",
"Durée du pipeline par étape",
["step"],
buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60, 120]
)
ai_tokens_consumed = Counter(
"hub_ai_tokens_consumed_total",
"Tokens AI consommés par client",
["client_id", "token_type"] # prompt / output
)
pipeline_errors = Counter(
"hub_pipeline_errors_total",
"Erreurs pipeline par étape",
["step"]
)
storage_used_bytes = Gauge(
"hub_storage_used_bytes",
"Espace disque utilisé par client",
["client_id"]
)
arq_queue_size = Gauge(
"hub_arq_queue_size",
"Tâches en attente dans ARQ",
["queue"]
)
```
- Incrémenter ces métriques aux bons endroits (upload, pipeline, suppression)
6. `app/main.py` — Améliorer `/health/detailed` :
```python
@app.get("/health/detailed")
async def health_detailed(db: AsyncSession = Depends(get_db)):
checks = {}
t0 = time()
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": round((time()-t0)*1000)}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Redis
try:
await app.state.redis.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "detail": str(e)}
# ARQ queue
try:
queue_info = await app.state.arq_pool.queued_jobs()
checks["queue"] = {"status": "ok", "pending_jobs": len(queue_info)}
except Exception as e:
checks["queue"] = {"status": "error", "detail": str(e)}
# Tesseract
try:
pytesseract.get_tesseract_version()
checks["tesseract"] = {"status": "ok"}
except Exception as e:
checks["tesseract"] = {"status": "error", "detail": str(e)}
# API Anthropic (vérification clé uniquement, sans appel facturable)
checks["anthropic"] = {
"status": "ok" if settings.ANTHROPIC_API_KEY else "error",
"configured": bool(settings.ANTHROPIC_API_KEY)
}
# Espace disque
try:
usage = shutil.disk_usage(settings.UPLOAD_DIR)
pct = round(usage.used / usage.total * 100, 1)
checks["disk"] = {
"status": "warning" if pct > 85 else "ok",
"used_pct": pct,
"free_gb": round(usage.free / 1e9, 2)
}
except Exception as e:
checks["disk"] = {"status": "error", "detail": str(e)}
overall = "healthy" if all(
c.get("status") in ("ok", "warning") for c in checks.values()
) else "degraded"
return {
"status": overall,
"version": settings.APP_VERSION,
"checks": checks,
"checked_at": datetime.utcnow().isoformat()
}
```
**Contraintes** :
- Zéro `print()` restant dans tout le projet après ce livrable
- Les logs de requêtes HTTP ne doivent pas logger les valeurs des headers `Authorization`
- Les métriques Prometheus ne doivent pas exposer de données sensibles (pas de `client_id` en clair dans les labels si trop nombreux — utiliser le `plan` à la place)
---
### Livrable 2.5 — CI/CD complet avec GitHub Actions (priorité HAUTE)
**Objectif** : automatiser entièrement la qualité du code, les tests, le build Docker et le déploiement.
**Nouvelles dépendances dev** — créer `requirements-dev.txt` :
```
pytest==8.3.0
pytest-asyncio==0.24.0
pytest-cov==5.0.0
httpx==0.27.2
ruff==0.6.0
black==24.8.0
mypy==1.11.0
bandit==1.7.10
pre-commit==3.8.0
faker==27.0.0 # génération de données de test
```
**Ce qui doit être créé** :
1. `.github/workflows/ci.yml` — Pipeline CI complet :
**Job `quality`** — sur chaque push et PR :
```yaml
- name: Lint (ruff)
run: ruff check . --output-format=github
- name: Format check (black)
run: black --check . --line-length 100
- name: Type check (mypy)
run: mypy app/ --strict --ignore-missing-imports
- name: Security scan (bandit)
run: bandit -r app/ -ll -x tests/
```
**Job `tests`** — sur chaque push et PR :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
steps:
- run: pytest tests/ -v --cov=app --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v4
```
**Job `docker`** — sur push vers `main` uniquement :
```yaml
needs: [quality, tests]
steps:
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
```
**Job `deploy-staging`** — sur push vers `main`, après `docker` :
- Déploiement vers un environnement de staging via SSH ou webhook
- Commenter dans le workflow s'il n'y a pas encore d'environnement de staging
2. `.pre-commit-config.yaml` — Hooks locaux :
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
args: [--strict, --ignore-missing-imports]
additional_dependencies: [types-all]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: [-ll, -x, tests/]
```
3. `pyproject.toml` — Configuration centralisée des outils :
```toml
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A"]
ignore = ["S101"] # autorise les assert dans les tests
[tool.black]
line-length = 100
target-version = ["py312"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["app/main.py", "*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
```
4. `Makefile` — Raccourcis de développement :
```makefile
.PHONY: dev test lint format typecheck security ci
dev:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
worker:
python worker.py
test:
pytest tests/ -v --cov=app --cov-report=term-missing
lint:
ruff check . --fix
format:
black . --line-length 100
typecheck:
mypy app/ --strict --ignore-missing-imports
security:
bandit -r app/ -ll -x tests/
ci: lint format typecheck security test
@echo "✅ Tous les checks CI passent"
migrate:
alembic upgrade head
docker-up:
docker-compose up -d
docker-logs:
docker-compose logs -f backend worker
```
5. `tests/test_pipeline_arq.py` — Tests du pipeline ARQ :
- Mocker `arq_pool.enqueue_job` pour vérifier que la bonne queue est utilisée selon le plan
- Tester que `process_image_task` est appelé avec les bons paramètres
- Tester le comportement en cas d'échec (image marquée `error` après `max_tries`)
- Tester que les événements Redis sont publiés aux bonnes étapes
6. `tests/test_storage.py` — Tests du StorageBackend :
- Tester `LocalStorage` avec un répertoire temporaire (`tmp_path` de pytest)
- Tester que les tokens HMAC expirent correctement
- Tester que `S3Storage` appelle les bonnes méthodes boto3 (avec `moto` pour mocker AWS)
- Tester la vérification de quota dans `save_upload()`
7. `tests/test_observability.py` — Tests des métriques et logs :
- Vérifier que `GET /metrics` retourne `HTTP 200` avec du texte Prometheus
- Vérifier que `GET /health/detailed` retourne un statut structuré
- Vérifier que les métriques `hub_images_uploaded_total` s'incrémentent à chaque upload
**Contraintes** :
- Le pipeline CI doit échouer si la couverture de tests descend sous 80%
- `mypy --strict` doit passer sans erreur sur tout le répertoire `app/`
- `bandit` ne doit rapporter aucun problème de sévérité HIGH ou MEDIUM
- Aucun secret ne doit apparaître dans les logs du CI (utiliser `${{ secrets.* }}` pour tout)
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que la Phase 1 est bien en place : `pytest tests/ -v` doit passer sans erreur
2. Lire les fichiers existants concernés **avant** de les modifier
3. S'assurer que Redis est disponible localement pour les tests ARQ
### Ordre de réalisation
Implémenter dans l'ordre strict : **2.1 → 2.2 → 2.3 → 2.4 → 2.5**. Valider chaque livrable avec `pytest` avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` et `requirements-dev.txt` si nécessaire
4. Créer la migration Alembic si le schéma change
5. Lancer `pytest tests/ -v` et corriger avant de passer au suivant
6. Pour le livrable 2.5, lancer `make ci` pour valider l'ensemble
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques
- **Zéro secret hardcodé** : tout passe par `app/config.py` + `.env`
- **Zéro `print()`** : remplacé par `structlog` dans tout le projet
- **Gestion explicite des exceptions** : chaque `except` doit logger + réagir, jamais `pass`
- **Fermeture propre des ressources** : pools Redis, connexions S3 fermées dans le lifespan
### Infrastructure locale pour le développement
Ajouter dans `docker-compose.yml` (si pas déjà présent) :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes
minio:
image: minio/minio
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes: ["minio_data:/data"]
volumes:
redis_data:
minio_data:
```
### Critère de succès final
Les commandes suivantes doivent toutes réussir sans erreur :
```bash
# Tests complets avec couverture
pytest tests/ -v --cov=app --cov-report=term-missing
# Qualité de code
make ci
# Démarrage du serveur
python run.py
# Démarrage du worker ARQ (dans un autre terminal)
python worker.py
# Vérification de santé
curl http://localhost:8000/health/detailed | python -m json.tool
```
La mise a jour des documentations API_GUIDE.md et README.md est aussi requise.
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **2.1** | `app/workers/image_worker.py`, `app/workers/redis_client.py`, `app/services/pipeline.py`, `app/routers/images.py`, `app/main.py`, `app/config.py`, `worker.py`, `docker-compose.yml` | `pytest tests/test_pipeline_arq.py` |
| **2.2** | `app/services/storage_backend.py`, `app/services/storage.py`, `app/routers/files.py`, `app/routers/images.py`, `app/main.py`, `app/config.py` | `pytest tests/test_storage.py` |
| **2.3** | `app/models/client.py`, `app/services/storage.py`, `app/routers/images.py`, `app/schemas/__init__.py` | `pytest tests/test_storage.py -k quota` |
| **2.4** | `app/logging_config.py`, `app/metrics.py`, `app/middleware/logging_middleware.py`, `app/main.py`, + remplacement de tous les `print()` dans `services/` et `workers/` | `pytest tests/test_observability.py` |
| **2.5** | `.github/workflows/ci.yml`, `.pre-commit-config.yaml`, `pyproject.toml`, `Makefile`, `requirements-dev.txt`, `tests/test_pipeline_arq.py`, `tests/test_storage.py`, `tests/test_observability.py` | `make ci` |
**Résultat final de la Phase 2 :** hub production-ready avec pipeline persistant et retry automatique, stockage abstrait compatible S3/MinIO, logs JSON structurés, métriques Prometheus et pipeline CI/CD complet.
</deliverable_summary>
+855
View File
@@ -0,0 +1,855 @@
# Prompt Claude Code — Phase 3 : Expérience développeur
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE3_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en conception d'APIs publiques, expérience développeur (DX) et intégration de systèmes. Tu travailles sur le projet Imago pour livrer sa phase finale : rendre ce hub aussi simple et agréable à intégrer que possible pour ses clients.
Les Phases 1 (sécurité, multi-tenants, rate limiting) et Phase 2 (ARQ + Redis, StorageBackend abstrait, structlog, Prometheus, CI/CD) sont entièrement terminées et validées. Tu prends le relais pour la dernière phase : WebSockets temps réel, versioning de l'API, SDK Python officiel, dashboard admin et intégration complète avec Shaarli.
</role>
<project_context>
## État du projet après Phase 1 et Phase 2
### Stack complète en place
```
# Phase 0 — Base
fastapi==0.115.0 # framework web async
uvicorn[standard]==0.30.6 # serveur ASGI
sqlalchemy[asyncio]==2.0.35 # ORM async
alembic==1.13.3 # migrations
pydantic-settings==2.5.2 # config depuis .env
pillow==10.4.0 # traitement images + thumbnails
piexif==1.1.3 # extraction EXIF avancée
pytesseract==0.3.13 # OCR Tesseract
anthropic==0.34.2 / httpx==0.27.2 # Vision AI Claude
beautifulsoup4==4.12.3 # scraping web
aiofiles==24.1.0 # I/O fichiers async
# Phase 1 — Sécurité
python-jose[cryptography]==3.3.0 # JWT
passlib[bcrypt]==1.7.4 # hachage API Keys
slowapi==0.1.9 # rate limiting par client/plan
# Phase 2 — Robustesse
arq==0.25.0 # file de tâches async persistante
redis==5.0.8 # client Redis (Pub/Sub + ARQ)
aioboto3==13.0.0 # stockage S3/MinIO/R2 async
structlog==24.4.0 # logs JSON structurés
prometheus-fastapi-instrumentator==0.14.0 # métriques Prometheus
```
### Structure complète du projet (après Phase 2)
```
imago/
├── app/
│ ├── main.py # App FastAPI, lifespan, middlewares, /metrics, /health
│ ├── config.py # Settings complets (auth, redis, S3, logging, quotas)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── logging_config.py # Configuration structlog JSON
│ ├── metrics.py # Compteurs/Histogrammes Prometheus custom
│ ├── models/
│ │ ├── image.py # Image (client_id FK, EXIF, OCR, AI, statut pipeline)
│ │ └── client.py # APIClient (id, name, hash, scopes, plan, quotas, storage_used)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets avec quotas
│ ├── dependencies/
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ ├── rate_limit.py # Rate limiting par plan (slowapi)
│ │ └── logging_middleware.py # Logs HTTP structurés par requête
│ ├── routers/
│ │ ├── auth.py # CRUD clients, rotation de clé (/auth/*)
│ │ ├── images.py # Endpoints images filtrés par client (/images/*)
│ │ ├── ai.py # Endpoints AI (/ai/summarize, /ai/draft-task)
│ │ └── files.py # Serveur fichiers signés (/files/signed/{token})
│ ├── services/
│ │ ├── storage_backend.py # ABC StorageBackend + LocalStorage + S3Storage
│ │ ├── storage.py # Orchestration upload/delete avec quota check
│ │ ├── exif_service.py # Extraction EXIF (piexif)
│ │ ├── ocr_service.py # OCR Tesseract
│ │ ├── ai_vision.py # Vision AI + summarize_url + draft_task
│ │ ├── scraper.py # Scraping web BeautifulSoup
│ │ └── pipeline.py # Orchestration EXIF→OCR→AI + publication Redis Pub/Sub
│ └── workers/
│ ├── image_worker.py # Worker ARQ (process_image_task, WorkerSettings)
│ └── redis_client.py # Pool Redis async partagé
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers, redis_mock
│ ├── test_services.py # Tests unitaires services (EXIF, OCR, storage)
│ ├── test_auth.py # Tests authentification et scopes
│ ├── test_isolation.py # Tests isolation multi-tenants
│ ├── test_rate_limit.py # Tests rate limiting par plan
│ ├── test_pipeline_arq.py # Tests worker ARQ (enqueue, retry, dead-letter)
│ ├── test_storage.py # Tests StorageBackend + quota + URLs signées
│ └── test_observability.py # Tests /metrics et /health/detailed
├── alembic/
│ └── versions/ # Migrations : images, api_clients, storage_used
├── worker.py # Point d'entrée : python worker.py
├── run.py # Point d'entrée serveur : python run.py
├── Makefile # make dev | test | ci | lint | docker-up
├── pyproject.toml # Config ruff, black, mypy, pytest, coverage
├── requirements.txt # Dépendances production
├── requirements-dev.txt # Dépendances dev (pytest, ruff, mypy, bandit...)
├── .pre-commit-config.yaml # Hooks pre-commit
├── .github/workflows/ci.yml # Pipeline CI/CD (quality + tests + docker + deploy)
├── Dockerfile
└── docker-compose.yml # backend + worker + redis + minio
```
### Ce qui manque encore (cible de la Phase 3)
**WebSockets absents** : le pipeline publie déjà sur Redis Pub/Sub (Phase 2), mais aucun endpoint WebSocket n'expose ces événements aux clients. Ils doivent encore poller `GET /images/{id}/status`.
**API non versionnée** : tous les endpoints sont sous `/images/`, `/ai/`, `/auth/`. Si l'on change un contrat, tous les clients cassent. Aucune politique de dépréciation.
**Pas de SDK** : chaque client doit réimplémenter l'upload, le polling, la gestion des erreurs, le retry. Shaarli devra faire de même.
**Dashboard admin absent** : aucune interface pour superviser les clients, leurs quotas et la santé du hub. Les opérations admin passent par des appels curl directs.
**Intégration Shaarli non finalisée** : Shaarli est le premier client officiel mais l'intégration n'est pas documentée, pas testée de bout en bout, et aucun exemple de code n'existe.
</project_context>
<mission>
## Mission : implémenter la Phase 3 — Expérience développeur
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 3.1 — WebSockets pipeline temps réel (priorité MOYENNE)
**Objectif** : exposer les événements Redis Pub/Sub aux clients via WebSocket, éliminant complètement le besoin de polling.
**Aucune nouvelle dépendance** : FastAPI supporte nativement les WebSockets via `websockets` (déjà inclus dans `uvicorn[standard]`).
**Ce qui doit être créé ou modifié** :
1. `app/routers/websocket.py` — Nouveau router WebSocket :
**Endpoint principal** :
```
WS /ws/pipeline/{image_id}?token=<api_key>
```
- Authentification via query param `token` (pas de header Authorization sur WebSocket)
- Valider que l'image appartient au client authentifié avant d'accepter la connexion
- S'abonner au channel Redis `pipeline:{image_id}`
- Pusher chaque message JSON reçu vers le client WebSocket
- Fermer proprement après réception de `pipeline.done` ou `pipeline.error`
- Gérer la déconnexion du client : si le client se déconnecte avant la fin, se désabonner proprement de Redis
**Endpoint de liste des pipelines actifs** (admin) :
```
WS /ws/admin/monitor?token=<admin_api_key>
```
- Nécessite le scope `admin`
- Pusher un événement à chaque fois qu'un pipeline démarre ou se termine sur n'importe quelle image
**Buffer de reconnexion** :
- Stocker les 10 derniers événements d'un pipeline dans Redis (clé `pipeline:buffer:{image_id}`, TTL 60s)
- Si un client se connecte après que le pipeline a démarré, envoyer d'abord les événements bufferisés puis continuer en live
**Format exact des messages** (doit correspondre à ce que publie `pipeline.py`) :
```json
{ "event": "pipeline.started", "image_id": 42, "steps": ["exif", "ocr", "ai"], "timestamp": "..." }
{ "event": "step.completed", "image_id": 42, "step": "exif", "duration_ms": 45, "data": { "camera": "Canon EOS R5", "has_gps": true } }
{ "event": "step.completed", "image_id": 42, "step": "ocr", "duration_ms": 820, "data": { "has_text": true, "preview": "Café de..." } }
{ "event": "step.completed", "image_id": 42, "step": "ai", "duration_ms": 3200, "data": { "tags": ["café", "paris"], "confidence": 0.97 } }
{ "event": "pipeline.done", "image_id": 42, "total_duration_ms": 4065, "status": "done" }
{ "event": "pipeline.error", "image_id": 42, "step": "ai", "error": "API timeout", "retry_attempt": 1 }
```
**Gestion des erreurs** :
- Si l'image est déjà en statut `done` quand le client se connecte → envoyer immédiatement un message `pipeline.done` synthétique et fermer
- Si l'image est en statut `error` → envoyer `pipeline.error` synthétique et fermer
- Si l'image n'appartient pas au client → fermer avec code WebSocket 4003 (Forbidden)
2. `app/metrics.py` — Ajouter la gauge `hub_active_websockets` :
- Incrémenter à l'ouverture d'une connexion WebSocket
- Décrémenter à la fermeture (succès ou erreur)
3. `app/main.py` — Monter le router WebSocket :
```python
from app.routers.websocket import router as ws_router
app.include_router(ws_router) # pas de préfixe /api/v1 — WS garde son propre préfixe /ws
```
4. `tests/test_websocket.py` — Tests complets :
- ✅ Connexion sans token → refus immédiat (code 4001)
- ✅ Connexion avec token valide → acceptée
- ✅ Image appartenant à un autre client → refus (code 4003)
- ✅ Image déjà `done` → reçoit `pipeline.done` synthétique et connexion fermée proprement
- ✅ Réception des événements dans l'ordre : started → step×3 → done
- ✅ Déconnexion client en cours de pipeline → désabonnement Redis propre (pas de goroutine/tâche en fuite)
- ✅ Reconnexion → buffer de 60s rejoué
Pour les tests, utiliser `httpx` avec `ASGITransport` ou le client WebSocket de `starlette.testclient` (`with client.websocket_connect(...)`)
---
### Livrable 3.2 — API versioning `/api/v1/` + politique de dépréciation (priorité MOYENNE)
**Objectif** : structurer l'API sous un préfixe versionné pour garantir la stabilité des contrats pour tous les clients intégrés.
**Ce qui doit être créé ou modifié** :
1. `app/main.py` — Restructurer le montage des routers :
**Avant** (sans versioning) :
```python
app.include_router(images_router)
app.include_router(ai_router)
app.include_router(auth_router)
```
**Après** (versioning propre) :
```python
from fastapi import APIRouter
# Router versionné v1
api_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
api_v1.include_router(images_router)
api_v1.include_router(ai_router)
api_v1.include_router(auth_router)
# WebSocket et fichiers signés — pas de préfixe /api/v1
app.include_router(ws_router) # /ws/*
app.include_router(files_router) # /files/*
# Routes non versionnées (infra)
# GET / → info
# GET /health
# GET /health/detailed
# GET /metrics
app.include_router(api_v1)
```
2. `app/middleware/versioning.py` — Middleware de versioning :
- Ajouter le header `X-API-Version: v1` sur toutes les réponses des routes `/api/v1/*`
- Préparer (en commentaire) l'ajout futur de `Deprecation: true` et `Sunset: <date>` quand v2 existera
- Logger un warning structlog si une route `/api/v1/*` est appelée après la date de sunset (configurable)
3. `app/config.py` — Ajouter :
```python
API_V1_SUNSET_DATE: str = "" # vide = pas de sunset, sinon "2027-06-01"
```
4. `app/main.py` — Mettre à jour la documentation OpenAPI :
```python
app = FastAPI(
title="Imago API",
version="1.0.0",
description="""
## Imago — API v1
Hub centralisé de gestion d'images avec pipeline AI automatique.
### Authentification
Toutes les routes `/api/v1/*` nécessitent un header :
```
Authorization: Bearer <votre_api_key>
```
### Versioning
Cette API respecte le versioning sémantique. La version actuelle est **v1**.
Les changements breaking seront introduits dans `/api/v2/` avec un préavis minimum de 12 mois.
### Rate Limiting
Les limites varient selon le plan :
- `free` : 20 uploads/h, 50 requêtes AI/h
- `standard` : 100 uploads/h, 200 requêtes AI/h
- `premium` : 500 uploads/h, 1000 requêtes AI/h
""",
openapi_tags=[
{"name": "Images", "description": "Gestion des images et pipeline AI"},
{"name": "Intelligence Artificielle", "description": "Résumé d'URL, rédaction de tâches"},
{"name": "Authentification", "description": "Gestion des clients API"},
{"name": "WebSocket", "description": "Notifications temps réel du pipeline"},
{"name": "Santé", "description": "Health checks et métriques"},
],
)
```
5. `tests/test_versioning.py` — Tests :
- ✅ Tous les endpoints images/ai/auth répondent sous `/api/v1/`
- ✅ Les anciennes URLs sans préfixe retournent `HTTP 404`
- ✅ Header `X-API-Version: v1` présent sur toutes les réponses `/api/v1/*`
- ✅ Header absent sur `/health`, `/metrics`, `/ws/*`
---
### Livrable 3.3 — SDK Python officiel `imago-client` (priorité MOYENNE)
**Objectif** : fournir un SDK Python idiomatique que n'importe quel client (Shaarli, scripts, autres apps) peut installer avec `pip install imago-client` et utiliser sans connaître les détails de l'API REST.
**Architecture du SDK** : le SDK est un **package Python séparé** dans le même dépôt (monorepo), dans le dossier `sdk/`.
**Structure du SDK** :
```
sdk/
├── pyproject.toml # Package config (build-system, metadata)
├── README.md # Documentation d'utilisation du SDK
├── imago_client/
│ ├── __init__.py # Exports publics : HubClient, HubImage, HubError
│ ├── client.py # HubClient — point d'entrée principal
│ ├── resources/
│ │ ├── images.py # ImagesResource — méthodes images
│ │ ├── ai.py # AIResource — résumé URL, tâches
│ │ └── auth.py # AuthResource — gestion clients (admin)
│ ├── models.py # Dataclasses : HubImage, HubExif, HubOcr, HubAI
│ ├── websocket.py # PipelineStream — suivi temps réel
│ ├── exceptions.py # HubError, AuthError, QuotaError, NotFoundError
│ └── utils.py # Retry, timeout, helpers
└── tests/
├── test_client.py
├── test_images.py
└── test_websocket_sdk.py
```
**Ce qui doit être créé** :
1. `sdk/imago_client/exceptions.py` :
```python
class HubError(Exception):
"""Erreur de base du SDK."""
def __init__(self, message: str, status_code: int | None = None):
self.status_code = status_code
super().__init__(message)
class AuthError(HubError):
"""Clé API invalide ou scope manquant (401/403)."""
class QuotaError(HubError):
"""Quota dépassé (413 ou 429)."""
class NotFoundError(HubError):
"""Ressource introuvable (404)."""
class PipelineError(HubError):
"""Erreur lors du traitement AI d'une image."""
```
2. `sdk/imago_client/models.py` — Dataclasses des réponses :
```python
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class HubExif:
camera_make: str | None = None
camera_model: str | None = None
taken_at: datetime | None = None
gps_latitude: float | None = None
gps_longitude: float | None = None
iso: int | None = None
aperture: str | None = None
@dataclass
class HubOcr:
has_text: bool = False
text: str | None = None
language: str | None = None
confidence: float | None = None
@dataclass
class HubAI:
description: str | None = None
tags: list[str] = field(default_factory=list)
confidence: float | None = None
model_used: str | None = None
@dataclass
class HubImage:
id: int
uuid: str
original_name: str
status: str # pending | processing | done | error
exif: HubExif = field(default_factory=HubExif)
ocr: HubOcr = field(default_factory=HubOcr)
ai: HubAI = field(default_factory=HubAI)
width: int | None = None
height: int | None = None
file_size: int | None = None
uploaded_at: datetime | None = None
```
3. `sdk/imago_client/websocket.py` — `PipelineStream` :
```python
class PipelineStream:
"""Suivi temps réel du pipeline via WebSocket avec fallback polling."""
async def __aiter__(self) -> AsyncIterator[PipelineEvent]:
"""Itère sur les événements du pipeline jusqu'à done/error."""
async def wait_until_done(self, timeout: float = 120.0) -> HubImage:
"""Attend la fin du pipeline et retourne l'image complète."""
async def _try_websocket(self) -> bool:
"""Tente la connexion WebSocket. Retourne False si indisponible."""
async def _fallback_polling(self, interval: float = 2.0) -> None:
"""Polling de /status si WebSocket indisponible."""
```
4. `sdk/imago_client/resources/images.py` — `ImagesResource` :
```python
class ImagesResource:
async def upload(
self,
file: str | Path | bytes | BinaryIO,
*,
filename: str | None = None,
) -> PipelineStream:
"""Upload une image et retourne un stream de suivi du pipeline."""
async def get(self, image_id: int) -> HubImage:
"""Récupère les détails complets d'une image."""
async def list(
self,
*,
page: int = 1,
page_size: int = 20,
tag: str | None = None,
status: str | None = None,
search: str | None = None,
) -> tuple[list[HubImage], int]: # (images, total)
"""Liste les images avec pagination et filtres."""
async def delete(self, image_id: int) -> None:
"""Supprime une image."""
async def get_download_url(self, image_id: int, expires_in: int = 900) -> str:
"""Retourne une URL signée pour télécharger l'image."""
async def reprocess(self, image_id: int) -> None:
"""Relance le pipeline AI sur une image existante."""
```
5. `sdk/imago_client/client.py` — `HubClient` :
```python
class HubClient:
def __init__(
self,
api_key: str,
base_url: str = "http://localhost:8000",
*,
timeout: float = 30.0,
max_retries: int = 3,
retry_backoff: float = 1.0,
):
self.images = ImagesResource(self)
self.ai = AIResource(self)
self.auth = AuthResource(self) # admin uniquement
async def __aenter__(self) -> "HubClient": ...
async def __aexit__(self, *args) -> None: ...
# Usage :
# async with HubClient(api_key="sk-...") as hub:
# stream = await hub.images.upload("photo.jpg")
# image = await stream.wait_until_done()
# print(image.ai.description)
```
6. `sdk/imago_client/resources/ai.py` — `AIResource` :
```python
class AIResource:
async def summarize_url(self, url: str, language: str = "français") -> dict:
"""Résumé AI d'une URL web."""
async def draft_task(
self, description: str, context: str | None = None, language: str = "français"
) -> dict:
"""Génère une tâche structurée depuis une description libre."""
```
7. `sdk/pyproject.toml` :
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "imago-client"
version = "1.0.0"
description = "SDK Python officiel pour le Imago"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27.0",
"websockets>=13.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio", "respx"] # respx pour mocker httpx
```
8. `sdk/README.md` — Documentation complète avec exemples :
```markdown
# imago-client
## Installation
pip install imago-client
## Usage rapide
```python
import asyncio
from imago_client import HubClient
async def main():
async with HubClient(api_key="sk-...", base_url="https://hub.example.com") as hub:
# Upload et attente du pipeline
stream = await hub.images.upload("photo.jpg")
image = await stream.wait_until_done(timeout=120)
print(f"Description : {image.ai.description}")
print(f"Tags : {', '.join(image.ai.tags)}")
print(f"Appareil : {image.exif.camera_make} {image.exif.camera_model}")
# Résumé d'URL
result = await hub.ai.summarize_url("https://example.com/article")
print(result["summary"])
```
9. `sdk/tests/` — Tests du SDK avec `respx` pour mocker httpx :
- ✅ `upload()` retourne un `PipelineStream` valide
- ✅ `wait_until_done()` complète via WebSocket
- ✅ `wait_until_done()` complète via polling si WebSocket indisponible
- ✅ `HubError` levée sur HTTP 4xx/5xx
- ✅ `AuthError` sur 401, `QuotaError` sur 413/429, `NotFoundError` sur 404
- ✅ Retry automatique sur erreurs 5xx (max_retries fois)
- ✅ Context manager `async with HubClient(...) as hub:` ferme les connexions proprement
---
### Livrable 3.4 — Dashboard admin (priorité FAIBLE)
**Objectif** : fournir une interface web légère permettant de superviser le hub sans passer par curl — visualiser les clients, leurs quotas, les métriques du pipeline et la santé des composants.
**Choix technique** : **pas de framework frontend séparé**. Le dashboard est une interface HTML/JS servie directement par FastAPI via `Jinja2Templates`. Simple, sans build step, déployé avec le backend.
**Nouvelle dépendance** :
```
jinja2==3.1.4
```
**Ce qui doit être créé** :
1. `app/routers/admin.py` — Router admin avec pages HTML et endpoints JSON :
**Pages HTML** (rendu Jinja2, scope `admin` requis) :
- `GET /admin/` → Dashboard overview (métriques globales, santé)
- `GET /admin/clients` → Liste des clients avec quotas et consommation
- `GET /admin/clients/{id}` → Détail d'un client (images, tokens AI, activité)
- `GET /admin/queue` → État de la file ARQ (jobs en attente, en cours, échoués)
- `GET /admin/storage` → Consommation disque par client
**Endpoints JSON** (appelés par le JS du dashboard) :
- `GET /admin/api/stats` → Statistiques globales (total images, tokens AI, taille stockage)
- `GET /admin/api/clients` → Liste clients avec métriques temps réel
- `GET /admin/api/queue/status` → Jobs ARQ en cours et en attente
- `POST /admin/api/clients/{id}/toggle` → Activer/désactiver un client
- `POST /admin/api/clients/{id}/reset-quota` → Réinitialiser les compteurs de quota
2. `app/templates/admin/` — Templates Jinja2 :
**`base.html`** — Layout commun :
- Sidebar avec navigation (Dashboard, Clients, Queue, Stockage)
- Header avec nom du hub et version
- Zone de contenu principale
- Style minimal avec CSS vanilla (pas de dépendance externe — tout inline ou dans `static/`)
**`dashboard.html`** — Page principale :
- Cartes métriques : total images, clients actifs, tokens AI consommés ce mois, espace utilisé
- Graphique simple (Chart.js CDN) : uploads par jour sur 30 jours
- Tableau santé des composants (BDD, Redis, ARQ, Tesseract, Anthropic) avec badge coloré
**`clients.html`** — Liste des clients :
- Tableau : nom, plan, images, stockage utilisé/quota, tokens AI ce mois, statut actif
- Badge coloré par plan (free = gris, standard = bleu, premium = or)
- Bouton activer/désactiver inline (appel AJAX vers `/admin/api/clients/{id}/toggle`)
- Lien vers le détail de chaque client
**`client_detail.html`** — Détail d'un client :
- En-tête : nom, plan, API key (masquée `sk-...****`), date de création
- Jauges de quota : images (X/Y), stockage (X MB / Y MB), tokens AI (X/Y)
- Tableau des 20 dernières images avec statut pipeline et tags AI
**`queue.html`** — État de la file ARQ :
- Compteurs : jobs en attente, en cours, réussis, échoués (dernières 24h)
- Tableau des jobs actifs : image_id, client, étape en cours, durée
- Tableau des jobs échoués récents avec message d'erreur
3. `app/static/admin/` — Fichiers statiques du dashboard :
- `style.css` — styles du dashboard (palette cohérente avec le projet)
- `dashboard.js` — rafraîchissement automatique des métriques toutes les 30s via `fetch`
4. `app/main.py` — Monter le dashboard :
```python
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
templates = Jinja2Templates(directory="app/templates")
app.mount("/admin/static", StaticFiles(directory="app/static/admin"), name="admin_static")
app.include_router(admin_router)
```
5. `tests/test_admin.py` — Tests du dashboard :
- ✅ `GET /admin/` sans scope `admin` → HTTP 403
- ✅ `GET /admin/` avec scope `admin` → HTTP 200 avec HTML valide
- ✅ `GET /admin/api/stats` → JSON avec les bons champs
- ✅ `POST /admin/api/clients/{id}/toggle` → change `is_active`
- ✅ Client désactivé → ses requêtes API retournent 401
---
### Livrable 3.5 — Intégration Shaarli complète + documentation (priorité HAUTE)
**Objectif** : finaliser l'intégration entre Shaarli (premier client officiel) et le hub, avec une documentation technique complète qui permettrait à n'importe quel développeur d'intégrer le hub en moins d'une heure.
**Ce qui doit être créé** :
1. `integration/shaarli/` — Module d'intégration Shaarli :
**`integration/shaarli/hub_plugin.py`** — Plugin Python utilisable depuis Shaarli :
```python
"""
Plugin d'intégration Shaarli ↔ Imago.
Installe dans Shaarli via : pip install imago-client
"""
import asyncio
from pathlib import Path
from imago_client import HubClient
class ShaarliHubPlugin:
"""
Enrichit les bookmarks Shaarli avec des images analysées par le hub.
Usage :
plugin = ShaarliHubPlugin(api_key="sk-...", hub_url="http://hub:8000")
# Sur ajout d'un bookmark avec image :
result = asyncio.run(plugin.process_bookmark_image("photo.jpg", bookmark_id=42))
"""
def __init__(self, api_key: str, hub_url: str):
self.hub_url = hub_url
self.api_key = api_key
async def process_bookmark_image(
self, image_path: str | Path, bookmark_id: int
) -> dict:
"""Upload une image associée à un bookmark et attend l'analyse AI."""
async with HubClient(self.api_key, self.hub_url) as hub:
stream = await hub.images.upload(image_path)
image = await stream.wait_until_done(timeout=120)
return {
"bookmark_id": bookmark_id,
"image_id": image.id,
"description": image.ai.description,
"tags": image.ai.tags,
"ocr_text": image.ocr.text,
}
async def enrich_bookmark_url(self, url: str) -> dict:
"""Génère un résumé AI d'un lien web pour enrichir un bookmark."""
async with HubClient(self.api_key, self.hub_url) as hub:
return await hub.ai.summarize_url(url)
```
**`integration/shaarli/config_example.env`** :
```bash
# Configuration du plugin Imago
HUB_URL=http://imago:8000
HUB_API_KEY=sk-votre-cle-api-ici
HUB_TIMEOUT=120
```
**`integration/shaarli/example_usage.py`** — Exemples commentés de tous les cas d'usage :
- Upload d'image depuis un bookmark
- Résumé d'URL pour un nouveau lien
- Recherche d'images par tag
- Gestion des erreurs (quota dépassé, timeout, réseau)
2. `docs/` — Documentation technique complète :
**`docs/getting-started.md`** — Guide de démarrage en 5 minutes :
```markdown
# Démarrage rapide
## 1. Lancer le hub
git clone ...
cp .env.example .env
# Éditer .env : ANTHROPIC_API_KEY=sk-ant-...
docker-compose up -d
# Hub disponible sur http://localhost:8000
## 2. Créer votre premier client
curl -X POST http://localhost:8000/api/v1/auth/clients \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Mon App", "plan": "standard", "scopes": ["images:read", "images:write", "ai:use"]}'
# → { "api_key": "sk-VOTRE-CLE-ICI" } ← la clé n'est affichée qu'une seule fois
## 3. Uploader votre première image
curl -X POST http://localhost:8000/api/v1/images/upload \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI" \
-F "[email protected]"
# → { "id": 1, "status": "pending", ... }
## 4. Suivre le pipeline en temps réel (WebSocket)
# Voir docs/websocket.md
## 5. Récupérer les résultats
curl http://localhost:8000/api/v1/images/1 \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI"
# → { "ai": { "description": "...", "tags": [...] }, "exif": {...}, "ocr": {...} }
```
**`docs/api-reference.md`** — Référence complète de tous les endpoints :
- Chaque endpoint documenté avec : méthode, URL, authentification requise, scopes, paramètres, exemple de réponse JSON
- Codes d'erreur possibles avec leur signification
**`docs/websocket.md`** — Guide WebSocket :
- Connexion, authentification via query param
- Format des événements (avec exemples JSON)
- Gestion de la reconnexion et du buffer 60s
- Exemples en Python, JavaScript et curl (via websocat)
**`docs/sdk.md`** — Guide du SDK Python :
- Installation
- Tous les cas d'usage avec exemples complets
- Gestion des erreurs (chaque exception du SDK documentée)
- Configuration avancée (timeout, retry, proxy)
**`docs/deployment.md`** — Guide de déploiement en production :
- Docker Compose (backend + worker + Redis + MinIO)
- Variables d'environnement obligatoires et optionnelles
- Passage de SQLite à PostgreSQL
- Passage de LocalStorage à S3/MinIO/R2
- Configuration Nginx en reverse proxy
- Recommandations de sécurité (HTTPS, headers, firewall)
**`docs/shaarli-integration.md`** — Guide d'intégration Shaarli :
- Installation du plugin
- Configuration
- Tous les cas d'usage (image bookmark, résumé URL, recherche par tag)
- Dépannage des problèmes courants
3. `tests/test_shaarli_integration.py` — Tests d'intégration end-to-end Shaarli :
- ✅ `ShaarliHubPlugin.process_bookmark_image()` → image uploadée + pipeline terminé
- ✅ `ShaarliHubPlugin.enrich_bookmark_url()` → résumé retourné
- ✅ Gestion d'une `QuotaError` (quota dépassé)
- ✅ Gestion d'un timeout (pipeline trop long)
- ✅ Plugin fonctionne sans event loop existant (appel depuis code synchrone Shaarli)
4. `CHANGELOG.md` — Historique des versions :
```markdown
# Changelog
## [1.0.0] — 2026-02-24
### Phase 3 — Expérience développeur
- WebSockets : suivi temps réel du pipeline avec buffer de reconnexion
- API versioning : tous les endpoints sous /api/v1/ avec header X-API-Version
- SDK Python officiel : imago-client 1.0.0 sur PyPI
- Dashboard admin : interface web de supervision des clients et quotas
- Intégration Shaarli : plugin + documentation complète
## [0.2.0] — Phase 2 — Robustesse et scalabilité
...
## [0.1.0] — Phase 1 — Fondations sécurité
...
```
5. `README.md` (racine) — Réécrire pour être le point d'entrée de toute la documentation :
- Badge CI (passing), badge version, badge Python
- Une phrase de description claire
- Liens rapides vers : getting-started, api-reference, sdk, déploiement
- Section "Architecture" avec le schéma texte du système
- Section "Clients supportés" : Shaarli (officiel) + instructions pour créer son propre client
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que les Phases 1 et 2 sont bien en place : `make ci` doit passer sans erreur
2. Redis doit être accessible en local pour les tests WebSocket
3. Lire les fichiers existants **avant** de les modifier — notamment `pipeline.py` (qui publie déjà sur Redis) et `main.py` (pour comprendre la structure de montage des routers)
### Ordre de réalisation
Implémenter dans l'ordre strict : **3.1 → 3.2 → 3.3 → 3.4 → 3.5**. Valider chaque livrable avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` si nécessaire
4. Lancer `pytest tests/ -v` après chaque livrable — corriger avant de passer au suivant
5. Pour le livrable 3.5 (docs), valider que tous les exemples de code dans la doc fonctionnent réellement
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques des modules créés
- **Zéro secret** : aucune clé, URL ou credential hardcodé
- **Gestion propre des connexions WebSocket** : toujours se désabonner de Redis Pub/Sub et fermer proprement même en cas d'exception
- **SDK** : doit fonctionner de manière totalement indépendante du backend — aucun import depuis `app/`
### Spécificités WebSocket (livrable 3.1)
- Tester la déconnexion client pendant le pipeline : vérifier qu'aucune tâche orpheline ne reste dans l'event loop
- Le buffer Redis (`pipeline:buffer:{image_id}`) doit avoir un TTL de 60 secondes — ne pas oublier de le configurer dans Redis
- Si le pipeline est déjà terminé quand le client se connecte, retourner immédiatement le résultat final depuis la BDD (pas depuis Redis)
### Spécificités SDK (livrable 3.3)
- Le SDK doit gérer le retry automatique sur les erreurs 5xx (backoff exponentiel)
- `wait_until_done()` doit tenter le WebSocket d'abord (timeout 5s pour la connexion), puis basculer automatiquement sur le polling si le WebSocket échoue ou est indisponible
- Les exceptions du SDK doivent contenir le `status_code` HTTP pour faciliter le débogage
- Le SDK ne doit pas avoir de dépendance vers `fastapi`, `sqlalchemy` ou tout autre élément du backend
### Critère de succès final
Les commandes suivantes doivent toutes réussir :
```bash
# Tests backend complets
pytest tests/ -v --cov=app --cov-report=term-missing
# Tests SDK
cd sdk && pytest tests/ -v
# Qualité globale
make ci
# Vérification manuelle du dashboard
python run.py &
python worker.py &
curl -s http://localhost:8000/admin/api/stats | python -m json.tool
# Test WebSocket rapide (nécessite websocat)
websocat "ws://localhost:8000/ws/pipeline/1?token=sk-test-key"
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **3.1** | `app/routers/websocket.py`, `app/metrics.py`, `app/main.py`, `tests/test_websocket.py` | `pytest tests/test_websocket.py` |
| **3.2** | `app/main.py`, `app/middleware/versioning.py`, `app/config.py`, `tests/test_versioning.py` | `pytest tests/test_versioning.py` |
| **3.3** | `sdk/` (package complet : client, resources, models, exceptions, websocket), `sdk/README.md`, `sdk/tests/` | `cd sdk && pytest tests/ -v` |
| **3.4** | `app/routers/admin.py`, `app/templates/admin/` (4 templates), `app/static/admin/`, `app/main.py`, `tests/test_admin.py` | `pytest tests/test_admin.py` |
| **3.5** | `integration/shaarli/`, `docs/` (5 guides), `CHANGELOG.md`, `README.md`, `tests/test_shaarli_integration.py` | `pytest tests/test_shaarli_integration.py` |
**Résultat final de la Phase 3 et du projet complet :**
- Hub multi-clients sécurisé, robuste, observable et entièrement documenté
- Pipeline AI avec suivi temps réel via WebSocket
- API versionnée stable avec contrat de dépréciation
- SDK Python officiel installable via pip
- Dashboard admin pour superviser clients et quotas
- Intégration Shaarli officielle avec documentation complète
- `make ci` vert, coverage > 80%, zéro warning mypy
</deliverable_summary>
Binary file not shown.
+735
View File
@@ -0,0 +1,735 @@
# Rapport d'amélioration — Imago
**Architecture d'un hub centralisé multi-clients**
| | |
|---|---|
| **Version** | 1.0.0 |
| **Date** | Février 2026 |
| **Statut** | Proposition — v1 Initiale |
| **Contexte** | Backend FastAPI — Hub image multi-clients |
---
## Table des matières
1. [Résumé exécutif](#1-résumé-exécutif)
2. [Axe 1 — Authentification et autorisation](#2-axe-1--authentification-et-autorisation)
3. [Axe 2 — Architecture multi-clients (tenants)](#3-axe-2--architecture-multi-clients-tenants)
4. [Axe 3 — Pipeline asynchrone robuste](#4-axe-3--pipeline-asynchrone-robuste)
5. [Axe 4 — Stockage et distribution des fichiers](#5-axe-4--stockage-et-distribution-des-fichiers)
6. [Axe 5 — WebSockets et notifications en temps réel](#6-axe-5--websockets-et-notifications-en-temps-réel)
7. [Axe 6 — Observabilité et monitoring](#7-axe-6--observabilité-et-monitoring)
8. [Axe 7 — Versioning de l'API et SDK clients](#8-axe-7--versioning-de-lapi-et-sdk-clients)
9. [Axe 8 — Tests, qualité de code et CI/CD](#9-axe-8--tests-qualité-de-code-et-cicd)
10. [Feuille de route proposée](#10-feuille-de-route-proposée)
11. [Stack technique finale recommandée](#11-stack-technique-finale-recommandée)
12. [Conclusion](#12-conclusion)
---
## 1. Résumé exécutif
Le backend Imago a été conçu initialement comme un complément à l'interface Shaarli pour gérer les images, l'OCR et les analyses AI. L'objectif a évolué : ce backend doit devenir un **hub centralisé** capable de servir plusieurs applications clientes simultanément.
Ce rapport identifie **8 axes d'amélioration** couvrant la sécurité, la scalabilité, la qualité du code et l'expérience développeur. Chaque axe est accompagné de recommandations concrètes, d'exemples de code et d'une estimation d'effort.
> **Contexte actuel**
> - Clients identifiés : Interface Shaarli (client 1), futures applications tierces
> - Technologies actuelles : FastAPI, SQLAlchemy async, Pillow, Tesseract, Anthropic Claude
> - Points critiques immédiats : absence d'authentification, pas de gestion multi-clients, pipeline non persistant
> - Horizon de développement proposé : 3 phases sur 3 mois
### 1.1 Synthèse des axes d'amélioration
| Axe d'amélioration | Impact principal | Priorité | Effort |
|---|---|:---:|:---:|
| Authentification & Autorisation | Sécurité multi-clients | 🔴 Critique | 3–5 j |
| Gestion multi-clients (tenants) | Isolation des données | 🔴 Critique | 5–7 j |
| Pipeline asynchrone robuste | Fiabilité du traitement AI | 🟠 Haute | 4–5 j |
| Stockage & CDN | Performance, scalabilité | 🟠 Haute | 3–4 j |
| WebSockets & temps réel | Expérience client | 🟡 Moyenne | 2–3 j |
| Observabilité & Monitoring | Production-ready | 🟡 Moyenne | 3–4 j |
| API versioning & SDK clients | Contrat API stable | 🟡 Moyenne | 2–3 j |
| Tests & CI/CD | Qualité et maintenabilité | 🟠 Haute | 4–5 j |
---
## 2. Axe 1 — Authentification et autorisation
### 2.1 Problématique
Dans l'état actuel, le backend est entièrement public. N'importe quelle application ou personne ayant accès au réseau peut uploader, lire ou supprimer des images. Cela est incompatible avec un hub servant plusieurs clients distincts.
> ⚠️ **Risques actuels**
> - Accès non contrôlé à l'ensemble des images et métadonnées
> - Consommation illimitée de tokens AI (coût financier direct)
> - Suppression accidentelle ou malveillante de données
### 2.2 Solution recommandée — API Keys + JWT
Pour un hub multi-clients, la combinaison la plus pragmatique est : des **API Keys** pour l'authentification machine-to-machine (clients automatiques, scripts), et des **tokens JWT** pour les sessions utilisateurs interactives.
| Mécanisme | Cas d'usage | Avantages |
|---|---|---|
| API Key | Shaarli, applications serveur, scripts CLI | Simple, stateless, facile à révoquer par client |
| JWT (Bearer) | Interface web, utilisateur connecté | Expiration automatique, payload riche |
| OAuth2 (futur) | Intégration systèmes tiers | Standard industriel, écosystème large |
### 2.3 Implémentation
Ajouter un modèle `APIClient` en base de données reliant chaque client à ses permissions et sa clé. Injecter une dépendance FastAPI `verify_api_key` qui valide la clé sur chaque requête. Définir des scopes granulaires : `images:read`, `images:write`, `images:delete`, `ai:use`.
```python
# app/models/client.py
class APIClient(Base):
__tablename__ = "api_clients"
id = Column(UUID, primary_key=True, default=uuid4)
name = Column(String, nullable=False) # "Shaarli", "App Mobile"
api_key_hash = Column(String, nullable=False) # SHA-256, jamais en clair
scopes = Column(JSON, default=list) # ["images:read", "ai:use"]
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
```
```python
# app/dependencies/auth.py
async def verify_api_key(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db)
) -> APIClient:
key = authorization.removeprefix("Bearer ").strip()
key_hash = hashlib.sha256(key.encode()).hexdigest()
result = await db.execute(
select(APIClient).where(APIClient.api_key_hash == key_hash)
)
client = result.scalar_one_or_none()
if not client or not client.is_active:
raise HTTPException(status_code=401, detail="Clé API invalide")
return client
```
> 💡 **Points d'implémentation**
> - Librairies : `python-jose` (JWT), `passlib` (hachage), `itsdangerous` (tokens signés)
> - Stockage des clés : hachées en base (sha256), **jamais en clair**
> - Rotation des clés : endpoint `POST /auth/rotate-key` avec période de transition
> - Audit log : chaque requête authentifiée loggée avec `client_id`, `action`, `timestamp`
---
## 3. Axe 2 — Architecture multi-clients (tenants)
### 3.1 Vision cible
Le hub doit servir plusieurs applications clientes avec une **isolation complète des données**. Shaarli est le client 1, mais d'autres applications peuvent s'inscrire et utiliser l'API de manière indépendante. Chaque client possède son propre espace de stockage, ses propres images et ses propres quotas.
| Concept | Description |
|---|---|
| **Client (Tenant)** | Une application consommatrice de l'API. Identifiée par son API Key. Ex : Shaarli, App Mobile, Script de backup. |
| **Espace de stockage** | Répertoire dédié : `uploads/{client_id}/`. Les images d'un client ne sont jamais accessibles par un autre. |
| **Quota** | Limite configurable : nb d'images, espace disque total, tokens AI consommés par mois. |
| **Plan** | Niveaux de service : `free` (limite stricte), `standard`, `premium` (OCR + AI complets). |
### 3.2 Modifications de la base de données
Ajouter une table `clients` avec les champs essentiels. Lier chaque image à son client via une clé étrangère `client_id`. Tous les endpoints filtrent automatiquement par `client_id` injecté depuis l'authentification.
| Champ | Type | Description |
|---|---|---|
| `id` | UUID | Identifiant unique du client |
| `name` | String | Nom de l'application cliente |
| `api_key_hash` | String | Hash SHA-256 de la clé (jamais en clair) |
| `scopes` | JSON | Permissions accordées : `["images:read", "ai:use", ...]` |
| `quota_images` | Integer | Nombre maximum d'images stockées |
| `quota_storage_mb` | Integer | Espace disque alloué en mégaoctets |
| `quota_ai_tokens` | Integer | Tokens AI mensuels autorisés |
| `is_active` | Boolean | Désactiver un client sans supprimer ses données |
| `created_at` | DateTime | Date d'inscription |
### 3.3 Injection automatique du client dans les endpoints
```python
# Avant (aucun contrôle)
@router.get("/images")
async def list_images(db: AsyncSession = Depends(get_db)):
return await db.execute(select(Image))
# Après (filtrage automatique par client)
@router.get("/images")
async def list_images(
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(verify_api_key), # ← injecté
):
query = select(Image).where(Image.client_id == client.id) # ← filtré
return await db.execute(query)
```
---
## 4. Axe 3 — Pipeline asynchrone robuste
### 4.1 Limites de l'implémentation actuelle
Le pipeline actuel utilise les `BackgroundTasks` de FastAPI. Cette approche est fonctionnelle pour un usage léger mais présente des limites importantes pour un hub en production.
> ⚠️ **Problèmes identifiés**
> - **Perte de tâches** : si le serveur redémarre, toutes les tâches en attente sont perdues (pas de persistance)
> - **Pas de limite de concurrence** : risque de saturer l'API Anthropic et de dépasser les quotas
> - **Pas de retry automatique** : un timeout API ou une erreur réseau = tâche perdue définitivement
> - **Pas de priorité** : un client `premium` attend autant qu'un client `free`
### 4.2 Solution — File de messages avec ARQ ou Celery
| Solution | Avantages | Inconvénients |
|---|---|---|
| **ARQ + Redis** | 100% async, léger, parfait pour FastAPI, retry natif | Nécessite Redis (service supplémentaire) |
| **Celery + Redis** | Très mature, monitoring Flower intégré, priorités | Plus lourd, mélange sync/async parfois délicat |
| **Dramatiq** | Simple, middleware extensible | Communauté plus petite qu'Celery |
**Recommandation : ARQ avec Redis** pour rester dans un écosystème async pur. Redis sert à la fois de broker de tâches et de cache pour les résultats AI.
```python
# app/workers/image_worker.py
import arq
async def process_image_task(ctx, image_id: int):
"""Tâche ARQ — remplace le BackgroundTask actuel."""
db = ctx["db"]
await process_image_pipeline(image_id, db)
class WorkerSettings:
functions = [process_image_task]
redis_settings = arq.connections.RedisSettings()
max_jobs = 10 # concurrence max
job_timeout = 180 # timeout par tâche (secondes)
retry_jobs = True
max_tries = 3 # retry automatique × 3
```
> 💡 **Points d'implémentation**
> - Retry configurable : 3 tentatives avec backoff exponentiel (1s, 4s, 16s)
> - Timeout par tâche : 120 secondes pour l'appel Vision AI, 30s pour l'OCR
> - Files distinctes : `queue:premium` (priorité haute) et `queue:standard`
> - Dead-letter queue : tâches échouées après 3 tentatives → alerte + log persistant
---
## 5. Axe 4 — Stockage et distribution des fichiers
### 5.1 Limites du stockage local
Le stockage sur disque local fonctionne pour un serveur unique mais devient problématique dès que l'on veut scaler horizontalement ou séparer le serveur applicatif du stockage.
> ⚠️ **Problèmes identifiés**
> - Le disque du serveur est partagé par tous les clients sans isolation
> - Pas de réplication — une panne disque = perte de toutes les images
> - Les URLs statiques exposent le chemin réel du fichier sur le serveur
> - Impossible de scaler sur plusieurs instances (fichiers non partagés)
### 5.2 Abstraction du stockage
Introduire une interface `StorageBackend` abstraite permettant de basculer entre stockage local et S3 sans modifier le reste du code.
```python
# app/services/storage_backend.py
from abc import ABC, abstractmethod
class StorageBackend(ABC):
@abstractmethod
async def save(self, file: bytes, path: str) -> str: ...
@abstractmethod
async def delete(self, path: str) -> None: ...
@abstractmethod
async def get_url(self, path: str, expires_in: int = 900) -> str: ...
class LocalStorage(StorageBackend):
"""Backend local — développement et serveur unique."""
async def save(self, file: bytes, path: str) -> str:
full_path = Path(settings.UPLOAD_DIR) / path
async with aiofiles.open(full_path, "wb") as f:
await f.write(file)
return str(full_path)
async def get_url(self, path: str, expires_in: int = 900) -> str:
# Token HMAC signé avec expiration
token = signing.dumps({"path": path, "exp": time() + expires_in})
return f"/files/{token}"
class S3Storage(StorageBackend):
"""Backend S3/MinIO/R2 — production."""
async def get_url(self, path: str, expires_in: int = 900) -> str:
# URL pré-signée AWS native
return await self.client.generate_presigned_url(
"get_object", Params={"Bucket": self.bucket, "Key": path},
ExpiresIn=expires_in
)
```
| Backend | Usage recommandé | Librairie | Coût |
|---|---|---|---|
| Local FileSystem | Développement, serveur unique | `aiofiles` (déjà présent) | Gratuit |
| MinIO (self-hosted) | Production on-premise | `aioboto3` | Infrastructure |
| AWS S3 | Production cloud | `aioboto3` | ~0.023$/GB/mois |
| Cloudflare R2 | Production cloud économique | `aioboto3` (compatible S3) | 0$ egress |
### 5.3 URLs signées et sécurité d'accès
Remplacer les routes `/static/` par des **URLs signées à durée limitée**. Le client reçoit une URL temporaire valide X minutes.
> 💡 **Points d'implémentation**
> - Endpoint : `GET /images/{id}/download-url` → retourne une URL signée valide 15 minutes
> - Paramètre optionnel : `expires_in` (en secondes, max configurable par plan)
> - Pour S3/R2 : URL pré-signée native. Pour stockage local : token HMAC signé par le serveur
> - Thumbnail : même mécanisme via `GET /images/{id}/thumbnail-url`
---
## 6. Axe 5 — WebSockets et notifications en temps réel
### 6.1 Problématique du polling
Actuellement, les clients doivent interroger `GET /images/{id}/status` régulièrement pour connaître l'avancement du pipeline. Cette approche génère du trafic inutile et ajoute de la latence perceptible.
### 6.2 Solution — WebSocket par session d'upload
Proposer un endpoint WebSocket que le client connecte après l'upload. Le serveur **pousse** des événements au fur et à mesure de l'avancement du pipeline.
**Endpoint :** `WS /ws/pipeline/{image_id}?token=<api_key>`
| Événement WebSocket | Payload |
|---|---|
| `pipeline.started` | `{ "image_id": 42, "steps": ["exif", "ocr", "ai"] }` |
| `step.completed` | `{ "step": "exif", "duration_ms": 45, "data": { "camera": "Canon EOS R5" } }` |
| `step.completed` | `{ "step": "ocr", "duration_ms": 820, "data": { "has_text": true, "preview": "Café de Flore..." } }` |
| `step.completed` | `{ "step": "ai", "duration_ms": 3200, "data": { "tags": ["café", "paris"], "confidence": 0.97 } }` |
| `pipeline.done` | `{ "image_id": 42, "total_duration_ms": 4100, "status": "done" }` |
| `pipeline.error` | `{ "step": "ai", "error": "API timeout", "retry": 1 }` |
```python
# app/routers/websocket.py
@router.websocket("/ws/pipeline/{image_id}")
async def pipeline_ws(
websocket: WebSocket,
image_id: int,
token: str = Query(...),
):
client = await verify_api_key_ws(token) # auth via query param
await websocket.accept()
# S'abonner aux événements Redis publiés par le pipeline
async with redis.subscribe(f"pipeline:{image_id}") as channel:
async for message in channel:
await websocket.send_json(message)
if message.get("event") in ("pipeline.done", "pipeline.error"):
break
```
> 💡 **Points d'implémentation**
> - Fallback automatique : si WebSocket non disponible, le client retombe sur le polling REST
> - FastAPI supporte nativement les WebSockets sans dépendance supplémentaire
> - Gestion de la déconnexion : événements bufferisés 60s si le client se reconnecte
> - Redis Pub/Sub : le pipeline publie ses événements, le WebSocket handler s'y abonne
---
## 7. Axe 6 — Observabilité et monitoring
### 7.1 Logs structurés
Remplacer les `print()` du code actuel par des **logs structurés en JSON**. Chaque log doit contenir au minimum : `timestamp`, `level`, `client_id`, `image_id`, `action`, `duration_ms`.
```python
# app/logging.py
import structlog
log = structlog.get_logger()
# Exemple d'usage dans le pipeline
log.info(
"pipeline.step.completed",
image_id=image.id,
client_id=client.id,
step="ocr",
duration_ms=820,
has_text=True,
)
# Sortie JSON en production :
# {"event": "pipeline.step.completed", "image_id": 42, "client_id": "abc",
# "step": "ocr", "duration_ms": 820, "has_text": true, "timestamp": "2026-02-23T..."}
```
> 💡 **Points d'implémentation**
> - Librairie recommandée : `structlog` (compatible FastAPI, format JSON natif)
> - Middleware de logs : enregistrer chaque requête HTTP avec méthode, path, status, duration, client_id
> - Niveaux : `DEBUG` (dev), `INFO` (prod normal), `WARNING` (anomalie récupérable), `ERROR` (action requise)
> - Exportation vers Loki, Datadog, CloudWatch selon infrastructure cible
### 7.2 Métriques applicatives
Exposer des métriques **Prometheus** sur `/metrics` pour un monitoring en temps réel.
| Métrique | Type | Utilité |
|---|---|---|
| `hub_images_uploaded_total` | Counter | Volume d'uploads par client |
| `hub_pipeline_duration_seconds` | Histogram | Temps de traitement p50/p95/p99 |
| `hub_ai_tokens_consumed_total` | Counter | Suivi des coûts AI par client |
| `hub_pipeline_errors_total` | Counter | Taux d'erreur par étape |
| `hub_storage_used_bytes` | Gauge | Espace disque par client |
| `hub_active_websockets` | Gauge | Connexions WebSocket actives |
```python
# main.py
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
```
### 7.3 Health checks détaillés
Améliorer `/health/detailed` pour qu'il vérifie activement chaque composant.
```python
@app.get("/health/detailed")
async def health_detailed():
checks = {}
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": 12}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Tesseract
checks["tesseract"] = _check_tesseract()
# API Anthropic
checks["anthropic"] = await _check_anthropic_api()
# Espace disque
usage = shutil.disk_usage(settings.UPLOAD_DIR)
checks["disk"] = {
"status": "ok" if usage.percent < 85 else "warning",
"used_pct": round(usage.percent, 1)
}
# File ARQ
checks["queue"] = await _check_arq_queue()
overall = "healthy" if all(c["status"] == "ok" for c in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
```
---
## 8. Axe 7 — Versioning de l'API et SDK clients
### 8.1 Versioning des endpoints
Un hub servant plusieurs clients ne peut pas modifier ses endpoints sans risquer de casser les intégrations existantes.
| Stratégie | Exemple | Recommandation |
|---|---|---|
| **URL path** | `/v1/images/upload`, `/v2/images/upload` | ✅ Simple, visible, **recommandé** |
| Header | `API-Version: 2024-01-15` | Flexible mais moins visible |
| Query param | `?version=1` | ❌ À éviter, pollue les URLs |
Adopter `/api/v1/` comme préfixe dès maintenant. Définir une **politique de dépréciation** : une version est supportée au minimum 12 mois après publication de la suivante.
```python
# main.py
from fastapi import APIRouter
v1 = APIRouter(prefix="/api/v1")
v1.include_router(images_router)
v1.include_router(ai_router)
app.include_router(v1)
# Middleware de dépréciation
@app.middleware("http")
async def deprecation_header(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/v1/"):
# Ajouter quand v2 sera disponible
# response.headers["Deprecation"] = "true"
# response.headers["Sunset"] = "2027-02-01"
pass
return response
```
### 8.2 SDK Python officiel
Générer un SDK Python à partir des schémas OpenAPI du backend.
```bash
# Génération automatique depuis /openapi.json
openapi-generator-cli generate \
-i http://localhost:8000/openapi.json \
-g python \
-o ./sdk \
--package-name imago_client
```
```python
# Exemple d'utilisation du SDK par les clients
from imago_client import HubClient
client = HubClient(api_key="sk-...", base_url="https://hub.example.com")
# Upload avec gestion automatique du pipeline
result = await client.images.upload("photo.jpg")
await result.wait_until_done() # WebSocket ou polling selon dispo
print(result.ai.description)
print(result.ai.tags)
print(result.exif.camera.model)
```
> 💡 **Points d'implémentation**
> - Package installable : `pip install imago-client`
> - Fonctionnalités : upload, polling, WebSocket, gestion des erreurs, retry automatique
> - Publication : PyPI (public) ou Gitea/GitHub Packages (privé)
---
## 9. Axe 8 — Tests, qualité de code et CI/CD
### 9.1 Stratégie de tests
| Niveau | Ce qu'on teste | Couverture cible | Outils |
|---|---|:---:|---|
| **Unitaires** | Services isolés (EXIF, OCR, storage) | 90%+ | `pytest`, `unittest.mock` |
| **Intégration** | Endpoints FastAPI + BDD de test | 80%+ | `httpx.AsyncClient`, SQLite in-memory |
| **End-to-End** | Pipeline complet upload → AI | Scénarios clés | `pytest-asyncio`, mocks API Anthropic |
| **Performance** | Charge concurrente, temps de réponse | Benchmarks | `locust`, `k6` |
```python
# tests/test_api_integration.py
import pytest
from httpx import AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_upload_and_pipeline():
async with AsyncClient(app=app, base_url="http://test") as client:
# Upload avec API Key de test
headers = {"Authorization": "Bearer test-key-123"}
with open("tests/fixtures/photo.jpg", "rb") as f:
response = await client.post(
"/api/v1/images/upload",
files={"file": f},
headers=headers
)
assert response.status_code == 201
data = response.json()
assert data["status"] == "pending"
# Vérifier le statut après pipeline (mocké)
status = await client.get(f"/api/v1/images/{data['id']}/status", headers=headers)
assert status.json()["status"] in ("processing", "done")
```
### 9.2 Qualité de code
| Outil | Rôle | Configuration |
|---|---|---|
| `ruff` | Linter ultra-rapide (remplace flake8 + isort) | `ruff check . --fix` |
| `black` | Formatage automatique | `black . --line-length 100` |
| `mypy` | Vérification des types statiques | `mypy app/ --strict` |
| `bandit` | Analyse de sécurité du code Python | `bandit -r app/` |
| `pre-commit` | Exécute tous les checks avant chaque commit | `.pre-commit-config.yaml` |
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
args: [--strict]
```
### 9.3 Pipeline CI/CD
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: black --check .
- run: mypy app/ --strict
- run: bandit -r app/ -ll
tests:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest tests/ -v --cov=app --cov-report=xml
- uses: codecov/codecov-action@v4
docker:
needs: [quality, tests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t imago:latest .
- run: docker push registry.example.com/imago:latest
```
> 💡 **Étapes du pipeline**
> - **Sur chaque Pull Request** : ruff + mypy + bandit + tests unitaires + tests d'intégration
> - **Sur merge main** : build image Docker + push registry + déploiement staging automatique
> - **Sur tag release** : déploiement production avec approbation manuelle
> - **Rapports** : couverture de tests publiée sur chaque PR, alertes Slack si échec
---
## 10. Feuille de route proposée
Les 8 axes ont été organisés en trois phases. Chaque phase est indépendante et livre de la valeur. Les phases ne se bloquent pas mutuellement, certains travaux peuvent commencer en parallèle.
### Phase 1 — Fondations sécurité (Semaines 1–3)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 1.1 | Authentification API Keys + JWT avec scopes | Axe 1 | 3–5 j | 🔴 Critique |
| 1.2 | Modèle clients + isolation des données | Axe 2 | 5–7 j | 🔴 Critique |
| 1.3 | Rate limiting par client et par endpoint | Axe 1 | 1–2 j | 🟠 Haute |
| 1.4 | Tests d'intégration auth + multi-tenants | Axe 8 | 2–3 j | 🟠 Haute |
**Résultat de la phase 1 :** hub sécurisé, données isolées par client, déployable en production.
### Phase 2 — Robustesse et scalabilité (Semaines 4–6)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 2.1 | Migration BackgroundTasks → ARQ + Redis | Axe 3 | 4–5 j | 🟠 Haute |
| 2.2 | Abstraction StorageBackend + support MinIO/S3 | Axe 4 | 3–4 j | 🟠 Haute |
| 2.3 | URLs signées pour accès aux fichiers | Axe 4 | 1–2 j | 🟠 Haute |
| 2.4 | Logs structurés (structlog) + métriques Prometheus | Axe 6 | 3–4 j | 🟡 Moyenne |
| 2.5 | CI/CD complet avec GitHub Actions | Axe 8 | 2–3 j | 🟠 Haute |
**Résultat de la phase 2 :** pipeline robuste avec retry, stockage abstrait, observabilité opérationnelle.
### Phase 3 — Expérience développeur (Semaines 7–10)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 3.1 | WebSocket pipeline temps réel | Axe 5 | 2–3 j | 🟡 Moyenne |
| 3.2 | API versioning `/api/v1/` + politique de dépréciation | Axe 7 | 1–2 j | 🟡 Moyenne |
| 3.3 | SDK Python généré + publié sur PyPI | Axe 7 | 3–4 j | 🟡 Moyenne |
| 3.4 | Dashboard admin (quotas, métriques, clients) | Axe 6 | 4–5 j | 🟢 Faible |
| 3.5 | Intégration Shaarli complète + documentation | Axe 7 | 2–3 j | 🟠 Haute |
**Résultat de la phase 3 :** hub avec SDK, WebSockets et intégration Shaarli finalisée.
---
## 11. Stack technique finale recommandée
La stack suivante est une **évolution directe** de la stack actuelle. Chaque ajout répond à un besoin identifié dans ce rapport. Rien n'est ajouté par effet de mode.
| Couche | Librairie | Justification |
|---|---|---|
| **Web Framework** | FastAPI + Uvicorn | Déjà en place. Performance async excellente. |
| **Base de données** | SQLAlchemy async + Alembic | Déjà en place. Migrations propres. |
| **File de tâches** | ARQ + Redis | Pipeline robuste, retry, persistance, priorités. |
| **Cache** | Redis (partagé avec ARQ) | Cache résultats AI, sessions WebSocket. |
| **Authentification** | python-jose + passlib | JWT + hachage des API Keys. |
| **Stockage fichiers** | Local → MinIO → S3/R2 | Interface abstraite. Migration transparente. |
| **Traitement images** | Pillow + piexif | Déjà en place. |
| **OCR** | pytesseract + Tesseract | Déjà en place. |
| **Vision AI** | Anthropic Claude (httpx) | Déjà en place. Abstraction multi-provider future. |
| **Logs** | structlog | JSON structuré, compatible Loki/Datadog. |
| **Métriques** | prometheus-fastapi-instrumentator | Exposition automatique des métriques HTTP. |
| **Qualité code** | ruff + black + mypy + bandit | Lint, format, types, sécurité. |
| **Tests** | pytest-asyncio + httpx | Tests unitaires et d'intégration async. |
| **CI/CD** | GitHub Actions / Gitea CI | Automatisation complète du cycle de vie. |
### requirements.txt mis à jour
```
# Existant
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
alembic==1.13.3
pydantic-settings==2.5.2
pillow==10.4.0
piexif==1.1.3
pytesseract==0.3.13
anthropic==0.34.2
httpx==0.27.2
beautifulsoup4==4.12.3
aiofiles==24.1.0
# Nouveaux — Phase 1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
slowapi==0.1.9 # rate limiting
# Nouveaux — Phase 2
arq==0.25.0 # file de tâches async
redis==5.0.8 # client Redis
aioboto3==13.0.0 # S3/MinIO async
structlog==24.4.0 # logs structurés
prometheus-fastapi-instrumentator==0.14.0
# Nouveaux — Phase 3
websockets==13.0 # WebSocket (déjà dans FastAPI)
```
---
## 12. Conclusion
Le backend Imago dispose d'une **base technique solide**. L'architecture FastAPI + SQLAlchemy async est bien choisie et évolutive. Les services EXIF, OCR et Vision AI sont fonctionnels et bien découpés.
La **priorité absolue** est la sécurisation avant tout passage en production multi-clients. L'authentification et l'isolation des données par client (Phase 1) sont des prérequis non négociables. Ces deux axes représentent environ 10 jours de développement.
Une fois la sécurité en place, la Phase 2 transforme le backend en un service de production véritable avec un pipeline robuste, un stockage abstrait et un monitoring opérationnel. La Phase 3 améliore l'expérience des équipes qui intègrent le hub.
> ✅ **Résumé des horizons**
> - **10 jours** : Phase 1 complète — hub sécurisé et multi-clients opérationnel
> - **20 jours** : Phase 2 complète — hub production-ready, robuste et observable
> - **30 jours** : Phase 3 complète — hub avec SDK, WebSockets et intégration Shaarli finalisée
---
*Imago — Rapport d'amélioration v1.0.0 — Février 2026*