feat(api): implement complete API layer with services and endpoints

Complete API implementation (BE-006 to BE-010):

BE-006: API Dependencies & Configuration
- Add core/config.py with Settings and environment variables
- Add core/exceptions.py with AppException hierarchy
- Add api/deps.py with get_db() and get_running_scenario() dependencies
- Add pydantic-settings dependency

BE-007: Services Layer
- Add services/pii_detector.py: PIIDetector with email/SSN/credit card patterns
- Add services/cost_calculator.py: AWS cost calculation (SQS, Lambda, Bedrock)
- Add services/ingest_service.py: Log processing with hash, PII detection, metrics

BE-008: Scenarios API Endpoints
- POST /api/v1/scenarios - Create scenario
- GET /api/v1/scenarios - List with filters and pagination
- GET /api/v1/scenarios/{id} - Get single scenario
- PUT /api/v1/scenarios/{id} - Update scenario
- DELETE /api/v1/scenarios/{id} - Delete scenario
- POST /api/v1/scenarios/{id}/start - Start (draft->running)
- POST /api/v1/scenarios/{id}/stop - Stop (running->completed)
- POST /api/v1/scenarios/{id}/archive - Archive (completed->archived)

BE-009: Ingest API
- POST /ingest with X-Scenario-ID header validation
- Depends on get_running_scenario() for status check
- Returns LogResponse with processed metrics
- POST /flush for backward compatibility

BE-010: Metrics API
- GET /api/v1/scenarios/{id}/metrics - Full metrics endpoint
- Aggregates data from scenario_logs
- Calculates costs using CostCalculator
- Returns cost breakdown (SQS/Lambda/Bedrock)
- Returns timeseries data grouped by hour

Refactored main.py:
- Simplified to use api_router
- Added exception handlers
- Added health check endpoint

All endpoints tested and working.

Tasks: BE-006, BE-007, BE-008, BE-009, BE-010 complete
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 14:35:50 +02:00
parent ebefc323c3
commit b18728f0f9
16 changed files with 1757 additions and 117 deletions

15
src/services/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""Services package."""
from src.services.pii_detector import PIIDetector, pii_detector, PIIDetectionResult
from src.services.cost_calculator import CostCalculator, cost_calculator
from src.services.ingest_service import IngestService, ingest_service
__all__ = [
"PIIDetector",
"pii_detector",
"PIIDetectionResult",
"CostCalculator",
"cost_calculator",
"IngestService",
"ingest_service",
]

View File

@@ -0,0 +1,86 @@
"""Cost calculation service."""
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from datetime import date
from src.repositories.base import BaseRepository
from src.models.aws_pricing import AwsPricing
class CostCalculator:
"""Service for calculating AWS costs."""
def __init__(self):
self.pricing_repo = BaseRepository(AwsPricing)
async def get_pricing(
self, db: AsyncSession, service: str, region: str, tier: str
) -> Optional[Decimal]:
"""Get active pricing for a service/region/tier."""
result = await db.execute(
select(AwsPricing).where(
and_(
AwsPricing.service == service,
AwsPricing.region == region,
AwsPricing.tier == tier,
AwsPricing.is_active == True,
)
)
)
pricing = result.scalar_one_or_none()
return pricing.price_per_unit if pricing else None
async def calculate_sqs_cost(
self, db: AsyncSession, blocks: int, region: str
) -> Decimal:
"""Calculate SQS cost."""
price = await self.get_pricing(db, "sqs", region, "standard")
if price is None:
price = Decimal("0.40")
# Formula: blocks * price_per_million / 1,000,000
return Decimal(blocks) * price / Decimal("1000000")
async def calculate_lambda_cost(
self, db: AsyncSession, invocations: int, gb_seconds: float, region: str
) -> Decimal:
"""Calculate Lambda cost (requests + compute)."""
request_price = await self.get_pricing(db, "lambda", region, "x86_request")
compute_price = await self.get_pricing(db, "lambda", region, "x86_compute")
if request_price is None:
request_price = Decimal("0.20")
if compute_price is None:
compute_price = Decimal("0.0000166667")
request_cost = Decimal(invocations) * request_price / Decimal("1000000")
compute_cost = Decimal(str(gb_seconds)) * compute_price
return request_cost + compute_cost
async def calculate_bedrock_cost(
self,
db: AsyncSession,
input_tokens: int,
output_tokens: int,
region: str,
model: str = "claude_3_sonnet",
) -> Decimal:
"""Calculate Bedrock LLM cost."""
input_price = await self.get_pricing(db, "bedrock", region, f"{model}_input")
output_price = await self.get_pricing(db, "bedrock", region, f"{model}_output")
if input_price is None:
input_price = Decimal("0.003")
if output_price is None:
output_price = Decimal("0.015")
input_cost = Decimal(input_tokens) * input_price / Decimal("1000")
output_cost = Decimal(output_tokens) * output_price / Decimal("1000")
return input_cost + output_cost
# Singleton instance
cost_calculator = CostCalculator()

View File

@@ -0,0 +1,65 @@
"""Log ingestion service."""
import hashlib
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.scenario_log import ScenarioLog
from src.models.scenario import Scenario
from src.repositories.scenario import scenario_repository
from src.services.pii_detector import pii_detector
from src.profiler import count_tokens, calculate_sqs_blocks
class IngestService:
"""Service for processing and ingesting logs."""
async def ingest_log(
self,
db: AsyncSession,
scenario: Scenario,
message: str,
source: str = "unknown",
) -> ScenarioLog:
"""Process and save a log entry."""
# Calculate message hash for deduplication
message_hash = hashlib.sha256(message.encode()).hexdigest()
# Truncate message for preview (privacy)
message_preview = message[:500] if len(message) > 500 else message
# Detect PII
has_pii = pii_detector.detect_simple(message)
# Calculate metrics
token_count = count_tokens(message)
payload_size = len(message.encode("utf-8"))
sqs_blocks = calculate_sqs_blocks(message)
# Create log entry
log_entry = ScenarioLog(
scenario_id=scenario.id,
received_at=datetime.utcnow(),
message_hash=message_hash,
message_preview=message_preview,
source=source,
size_bytes=payload_size,
has_pii=has_pii,
token_count=token_count,
sqs_blocks=sqs_blocks,
)
db.add(log_entry)
# Update scenario metrics
await scenario_repository.increment_total_requests(db, scenario.id)
await db.commit()
await db.refresh(log_entry)
return log_entry
# Singleton instance
ingest_service = IngestService()

View File

@@ -0,0 +1,53 @@
"""PII detection service."""
import re
from typing import Dict, List
from dataclasses import dataclass
@dataclass
class PIIDetectionResult:
"""Result of PII detection."""
has_pii: bool
pii_types: List[str]
total_matches: int
details: Dict[str, List[str]]
class PIIDetector:
"""Service for detecting PII in messages."""
# Regex patterns for common PII
PATTERNS = {
"email": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"ssn": r"\b\d{3}-\d{2}-\d{4}\b",
"credit_card": r"\b(?:\d[ -]*?){13,16}\b",
"phone": r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b",
"ip_address": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
}
def detect(self, message: str) -> PIIDetectionResult:
"""Detect PII in a message."""
results = {}
for pii_type, pattern in self.PATTERNS.items():
matches = re.findall(pattern, message)
if matches:
results[pii_type] = matches
return PIIDetectionResult(
has_pii=len(results) > 0,
pii_types=list(results.keys()),
total_matches=sum(len(matches) for matches in results.values()),
details=results,
)
def detect_simple(self, message: str) -> bool:
"""Simple PII detection - returns True if PII found."""
# Quick check for email (most common)
return "@" in message and ".com" in message
# Singleton instance
pii_detector = PIIDetector()