# Prompt di Ingaggio: API Pubblica (T35-T40) ## ๐ŸŽฏ MISSIONE Implementare la fase **API Pubblica** del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD. **Task da completare:** T35, T36, T37, T38, T39, T40 --- ## ๐Ÿ“‹ CONTESTO **AGENTE:** @tdd-developer **Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher` **Stato Attuale:** - โœ… Setup (T01-T05): 59 test - โœ… Database & Models (T06-T11): 73 test - โœ… Security Services (T12-T16): 70 test - โœ… User Authentication (T17-T22): 34 test - โœ… Gestione API Keys (T23-T29): 61 test - โœ… Dashboard & Statistiche (T30-T34): 27 test - ๐ŸŽฏ **Totale: 324+ test, ~98% coverage su moduli implementati** **Servizi Pronti:** - `EncryptionService` - Cifratura/decifratura - `get_current_user()` - Autenticazione JWT - `generate_api_token()`, `verify_api_token()` - Token API pubblica - `get_dashboard_data()`, `get_usage_stats()` - Aggregazione dati - `ApiKey`, `UsageStats`, `ApiToken` models **Documentazione:** - PRD: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md` (sezione 2.4) - Architecture: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md` (sezione 5.2.3) --- ## ๐Ÿ”ง TASK DA IMPLEMENTARE ### T35: Creare Pydantic Schemas per API Pubblica **File:** `src/openrouter_monitor/schemas/public_api.py` **Requisiti:** - `PublicStatsResponse`: summary (requests, cost, tokens), period (start_date, end_date) - `PublicUsageResponse`: items (list), pagination (page, limit, total, pages) - `PublicKeyInfo`: id, name, is_active, stats (total_requests, total_cost) - `PublicKeyListResponse`: items (list[PublicKeyInfo]), total - `ApiTokenCreate`: name (str, 1-100 chars) - `ApiTokenResponse`: id, name, created_at, last_used_at, is_active (NO token!) - `ApiTokenCreateResponse`: id, name, token (plaintext, solo al momento creazione), created_at **Implementazione:** ```python from pydantic import BaseModel, Field from datetime import date, datetime from typing import List, Optional from decimal import Decimal class PeriodInfo(BaseModel): start_date: date end_date: date days: int class PublicStatsSummary(BaseModel): total_requests: int total_cost: Decimal total_tokens_input: int total_tokens_output: int class PublicStatsResponse(BaseModel): summary: PublicStatsSummary period: PeriodInfo class PublicUsageItem(BaseModel): date: date model: str requests_count: int tokens_input: int tokens_output: int cost: Decimal class PaginationInfo(BaseModel): page: int limit: int total: int pages: int class PublicUsageResponse(BaseModel): items: List[PublicUsageItem] pagination: PaginationInfo class PublicKeyStats(BaseModel): total_requests: int total_cost: Decimal class PublicKeyInfo(BaseModel): id: int name: str is_active: bool stats: PublicKeyStats class PublicKeyListResponse(BaseModel): items: List[PublicKeyInfo] total: int class ApiTokenCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) class ApiTokenResponse(BaseModel): id: int name: str created_at: datetime last_used_at: Optional[datetime] is_active: bool class ApiTokenCreateResponse(BaseModel): id: int name: str token: str # PLAINTEXT - shown only once! created_at: datetime ``` **Test:** `tests/unit/schemas/test_public_api_schemas.py` (10+ test) --- ### T36: Implementare Endpoint GET /api/v1/stats (API Pubblica) **File:** `src/openrouter_monitor/routers/public_api.py` **Requisiti:** - Endpoint: `GET /api/v1/stats` - Auth: API Token (non JWT!) - `get_current_user_from_api_token()` - Query params: - start_date (optional, default 30 giorni fa) - end_date (optional, default oggi) - Verifica token valido e attivo - Aggiorna `last_used_at` del token - Ritorna: `PublicStatsResponse` - Solo lettura, nessuna modifica **Implementazione:** ```python from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from datetime import date, timedelta from openrouter_monitor.database import get_db from openrouter_monitor.dependencies import get_current_user_from_api_token from openrouter_monitor.models import User from openrouter_monitor.schemas import PublicStatsResponse from openrouter_monitor.services.stats import get_public_stats router = APIRouter(prefix="/api/v1", tags=["public-api"]) @router.get("/stats", response_model=PublicStatsResponse) async def get_public_stats_endpoint( start_date: Optional[date] = Query(default=None), end_date: Optional[date] = Query(default=None), current_user: User = Depends(get_current_user_from_api_token), db: Session = Depends(get_db) ): """Get usage statistics via API token authentication. Authentication: Bearer Returns aggregated statistics for the authenticated user's API keys. """ # Default to last 30 days if dates not provided if not end_date: end_date = date.today() if not start_date: start_date = end_date - timedelta(days=29) # Get stats using existing service stats = await get_public_stats(db, current_user.id, start_date, end_date) return PublicStatsResponse( summary=stats, period=PeriodInfo( start_date=start_date, end_date=end_date, days=(end_date - start_date).days + 1 ) ) ``` **Test:** - Test con token valido (200) - Test con token invalido (401) - Test con token scaduto/revocado (401) - Test date default (30 giorni) - Test date custom - Test aggiornamento last_used_at --- ### T37: Implementare Endpoint GET /api/v1/usage (API Pubblica) **File:** `src/openrouter_monitor/routers/public_api.py` **Requisiti:** - Endpoint: `GET /api/v1/usage` - Auth: API Token - Query params: - start_date (required) - end_date (required) - page (default 1) - limit (default 100, max 1000) - Paginazione con offset/limit - Ritorna: `PublicUsageResponse` **Implementazione:** ```python @router.get("/usage", response_model=PublicUsageResponse) async def get_public_usage_endpoint( start_date: date, end_date: date, page: int = Query(default=1, ge=1), limit: int = Query(default=100, ge=1, le=1000), current_user: User = Depends(get_current_user_from_api_token), db: Session = Depends(get_db) ): """Get detailed usage data via API token authentication. Returns paginated usage records aggregated by date and model. """ skip = (page - 1) * limit # Get usage data items, total = await get_public_usage( db, current_user.id, start_date, end_date, skip, limit ) pages = (total + limit - 1) // limit return PublicUsageResponse( items=items, pagination=PaginationInfo( page=page, limit=limit, total=total, pages=pages ) ) ``` **Test:** - Test con filtri date (200) - Test paginazione - Test limit max 1000 - Test senza token (401) - Test token scaduto (401) --- ### T38: Implementare Endpoint GET /api/v1/keys (API Pubblica) **File:** `src/openrouter_monitor/routers/public_api.py` **Requisiti:** - Endpoint: `GET /api/v1/keys` - Auth: API Token - Ritorna: lista API keys con statistiche aggregate - NO key values (cifrate comunque) - Solo: id, name, is_active, stats (totali) **Implementazione:** ```python @router.get("/keys", response_model=PublicKeyListResponse) async def get_public_keys_endpoint( current_user: User = Depends(get_current_user_from_api_token), db: Session = Depends(get_db) ): """Get API keys list with aggregated statistics. Returns non-sensitive key information with usage stats. Key values are never exposed. """ from sqlalchemy import func # Query API keys with aggregated stats results = db.query( ApiKey.id, ApiKey.name, ApiKey.is_active, func.coalesce(func.sum(UsageStats.requests_count), 0).label('total_requests'), func.coalesce(func.sum(UsageStats.cost), 0).label('total_cost') ).outerjoin(UsageStats).filter( ApiKey.user_id == current_user.id ).group_by(ApiKey.id).all() items = [ PublicKeyInfo( id=r.id, name=r.name, is_active=r.is_active, stats=PublicKeyStats( total_requests=r.total_requests, total_cost=Decimal(str(r.total_cost)) ) ) for r in results ] return PublicKeyListResponse(items=items, total=len(items)) ``` **Test:** - Test lista keys con stats (200) - Test NO key values in risposta - Test senza token (401) --- ### T39: Implementare Rate Limiting su API Pubblica **File:** `src/openrouter_monitor/middleware/rate_limit.py` o `src/openrouter_monitor/dependencies/rate_limit.py` **Requisiti:** - Rate limit per API token: 100 richieste/ora (default) - Rate limit per IP: 30 richieste/minuto (fallback) - Memorizzare contatori in memory (per MVP, Redis in futuro) - Header nelle risposte: X-RateLimit-Limit, X-RateLimit-Remaining - Ritorna 429 Too Many Requests quando limite raggiunto **Implementazione:** ```python from fastapi import HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from datetime import datetime, timedelta from typing import Dict, Tuple import time # Simple in-memory rate limiting (use Redis in production) class RateLimiter: def __init__(self): self._storage: Dict[str, Tuple[int, float]] = {} # key: (count, reset_time) def is_allowed(self, key: str, limit: int, window_seconds: int) -> Tuple[bool, int, int]: """Check if request is allowed. Returns (allowed, remaining, limit).""" now = time.time() reset_time = now + window_seconds if key not in self._storage: self._storage[key] = (1, reset_time) return True, limit - 1, limit count, current_reset = self._storage[key] # Reset window if expired if now > current_reset: self._storage[key] = (1, reset_time) return True, limit - 1, limit # Check limit if count >= limit: return False, 0, limit self._storage[key] = (count + 1, current_reset) return True, limit - count - 1, limit rate_limiter = RateLimiter() async def rate_limit_by_token( credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), request: Request = None ) -> None: """Rate limiting dependency for API endpoints.""" from openrouter_monitor.config import get_settings settings = get_settings() # Use token as key if available, otherwise IP if credentials: key = f"token:{credentials.credentials}" limit = settings.rate_limit_requests # 100/hour window = settings.rate_limit_window # 3600 seconds else: key = f"ip:{request.client.host}" limit = 30 # 30/minute for IP window = 60 allowed, remaining, limit_total = rate_limiter.is_allowed(key, limit, window) if not allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Rate limit exceeded. Try again later.", headers={"Retry-After": str(window)} ) # Add rate limit headers to response (will be added by middleware) request.state.rate_limit_remaining = remaining request.state.rate_limit_limit = limit_total class RateLimitHeadersMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] == "http": request = Request(scope, receive) async def send_with_headers(message): if message["type"] == "http.response.start": headers = message.get("headers", []) # Add rate limit headers if available if hasattr(request.state, 'rate_limit_remaining'): headers.append( (b"x-ratelimit-remaining", str(request.state.rate_limit_remaining).encode()) ) headers.append( (b"x-ratelimit-limit", str(request.state.rate_limit_limit).encode()) ) message["headers"] = headers await send(message) await self.app(scope, receive, send_with_headers) else: await self.app(scope, receive, send) ``` **Aggiungere ai router:** ```python from openrouter_monitor.dependencies.rate_limit import rate_limit_by_token @router.get("/stats", response_model=PublicStatsResponse, dependencies=[Depends(rate_limit_by_token)]) async def get_public_stats_endpoint(...): ... ``` **Test:** - Test rate limit token (100/ora) - Test rate limit IP (30/minuto) - Test 429 quando limite raggiunto - Test headers X-RateLimit-* presenti - Test reset dopo window --- ### T40: Scrivere Test per API Pubblica **File:** `tests/unit/routers/test_public_api.py` **Requisiti:** - Test integrazione per tutti gli endpoint API pubblica - Mock/generare API token validi per test - Test rate limiting - Test sicurezza (token invalido, scaduto) - Coverage >= 90% **Test da implementare:** - **Stats Tests:** - GET /api/v1/stats con token valido (200) - GET /api/v1/stats date default (30 giorni) - GET /api/v1/stats date custom - GET /api/v1/stats token invalido (401) - GET /api/v1/stats token scaduto (401) - GET /api/v1/stats aggiorna last_used_at - **Usage Tests:** - GET /api/v1/usage con filtri (200) - GET /api/v1/usage paginazione - GET /api/v1/usage senza token (401) - **Keys Tests:** - GET /api/v1/keys lista (200) - GET /api/v1/keys NO key values in risposta - **Rate Limit Tests:** - Test 100 richieste/ora - Test 429 dopo limite - Test headers rate limit - **Security Tests:** - User A non vede dati di user B con token di A - Token JWT non funziona su API pubblica (401) --- ## ๐Ÿ”„ WORKFLOW TDD Per **OGNI** task: 1. **RED**: Scrivi test che fallisce (prima del codice!) 2. **GREEN**: Implementa codice minimo per passare il test 3. **REFACTOR**: Migliora codice, test rimangono verdi --- ## ๐Ÿ“ STRUTTURA FILE DA CREARE ``` src/openrouter_monitor/ โ”œโ”€โ”€ schemas/ โ”‚ โ”œโ”€โ”€ __init__.py # Aggiungi export public_api โ”‚ โ””โ”€โ”€ public_api.py # T35 โ”œโ”€โ”€ routers/ โ”‚ โ”œโ”€โ”€ __init__.py # Aggiungi export public_api โ”‚ โ””โ”€โ”€ public_api.py # T36, T37, T38 โ”œโ”€โ”€ dependencies/ โ”‚ โ”œโ”€โ”€ __init__.py # Aggiungi export โ”‚ โ”œโ”€โ”€ auth.py # Aggiungi get_current_user_from_api_token โ”‚ โ””โ”€โ”€ rate_limit.py # T39 โ”œโ”€โ”€ middleware/ โ”‚ โ””โ”€โ”€ rate_limit.py # T39 (opzionale) โ””โ”€โ”€ main.py # Registra public_api router + middleware tests/unit/ โ”œโ”€โ”€ schemas/ โ”‚ โ””โ”€โ”€ test_public_api_schemas.py # T35 + T40 โ”œโ”€โ”€ dependencies/ โ”‚ โ””โ”€โ”€ test_rate_limit.py # T39 + T40 โ””โ”€โ”€ routers/ โ””โ”€โ”€ test_public_api.py # T36-T38 + T40 ``` --- ## ๐Ÿงช ESEMPI TEST ### Test Dependency API Token ```python @pytest.mark.asyncio async def test_get_current_user_from_api_token_valid_returns_user(db_session, test_user): # Arrange token, token_hash = generate_api_token() api_token = ApiToken(user_id=test_user.id, token_hash=token_hash, name="Test") db_session.add(api_token) db_session.commit() # Act user = await get_current_user_from_api_token(token, db_session) # Assert assert user.id == test_user.id ``` ### Test Endpoint Stats ```python def test_public_stats_with_valid_token_returns_200(client, api_token): response = client.get( "/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"} ) assert response.status_code == 200 assert "summary" in response.json() ``` ### Test Rate Limiting ```python def test_rate_limit_429_after_100_requests(client, api_token): # Make 100 requests for _ in range(100): response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"}) assert response.status_code == 200 # 101st request should fail response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"}) assert response.status_code == 429 ``` --- ## โœ… CRITERI DI ACCETTAZIONE - [ ] T35: Schemas API pubblica con validazione - [ ] T36: Endpoint /api/v1/stats con auth API token - [ ] T37: Endpoint /api/v1/usage con paginazione - [ ] T38: Endpoint /api/v1/keys con stats aggregate - [ ] T39: Rate limiting implementato (100/ora, 429) - [ ] T40: Test completi coverage >= 90% - [ ] `get_current_user_from_api_token()` dependency funzionante - [ ] Headers X-RateLimit-* presenti nelle risposte - [ ] Token JWT non funziona su API pubblica - [ ] 6 commit atomici con conventional commits - [ ] progress.md aggiornato --- ## ๐Ÿ“ COMMIT MESSAGES ``` feat(schemas): T35 add Pydantic public API schemas feat(auth): add get_current_user_from_api_token dependency feat(public-api): T36 implement GET /api/v1/stats endpoint feat(public-api): T37 implement GET /api/v1/usage endpoint with pagination feat(public-api): T38 implement GET /api/v1/keys endpoint feat(rate-limit): T39 implement rate limiting for public API test(public-api): T40 add comprehensive public API endpoint tests ``` --- ## ๐Ÿš€ VERIFICA FINALE ```bash cd /home/google/Sources/LucaSacchiNet/openrouter-watcher # Test schemas pytest tests/unit/schemas/test_public_api_schemas.py -v # Test dependencies pytest tests/unit/dependencies/test_rate_limit.py -v # Test routers pytest tests/unit/routers/test_public_api.py -v --cov=src/openrouter_monitor/routers # Test completo pytest tests/unit/ -v --cov=src/openrouter_monitor # Verifica endpoint manualmente curl -H "Authorization: Bearer or_api_xxxxx" http://localhost:8000/api/v1/stats ``` --- ## ๐Ÿ“‹ DIFFERENZE CHIAVE: API Pubblica vs Web API | Feature | Web API (/api/auth, /api/keys) | API Pubblica (/api/v1/*) | |---------|--------------------------------|--------------------------| | **Auth** | JWT Bearer | API Token Bearer | | **Scopo** | Gestione (CRUD) | Lettura dati | | **Rate Limit** | No (o diverso) | Sรฌ (100/ora) | | **Audience** | Frontend web | Integrazioni esterne | | **Token TTL** | 24 ore | Illimitato (fino a revoca) | --- ## ๐Ÿ”’ CONSIDERAZIONI SICUREZZA ### Do's โœ… - Verificare sempre API token con hash in database - Aggiornare `last_used_at` ad ogni richiesta - Rate limiting per prevenire abusi - Non esporre mai API key values (cifrate) - Validare date (max range 365 giorni) ### Don'ts โŒ - MAI accettare JWT su API pubblica - MAI loggare API token in plaintext - MAI ritornare dati di altri utenti - MAI bypassare rate limiting - MAI permettere range date > 365 giorni --- ## ๐Ÿ“ NOTE IMPORTANTI - **Path assoluti**: Usa sempre `/home/google/Sources/LucaSacchiNet/openrouter-watcher/` - **Dependency**: Crea `get_current_user_from_api_token()` separata da `get_current_user()` - **Rate limiting**: In-memory per MVP, Redis per produzione - **Token format**: API token inizia con `or_api_`, JWT no - **last_used_at**: Aggiornare ad ogni chiamata API pubblica --- **AGENTE:** @tdd-developer **INIZIA CON:** T35 - Pydantic public API schemas **QUANDO FINITO:** Conferma completamento, coverage >= 90%, aggiorna progress.md