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

1
src/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

39
src/api/deps.py Normal file
View File

@@ -0,0 +1,39 @@
"""API dependencies."""
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.database import AsyncSessionLocal
from src.repositories.scenario import scenario_repository, ScenarioStatus
from src.core.exceptions import NotFoundException, ScenarioNotRunningException
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency that provides a database session."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def get_running_scenario(
scenario_id: str = Header(..., alias="X-Scenario-ID"),
db: AsyncSession = Depends(get_db),
):
"""Dependency that validates scenario exists and is running."""
try:
scenario_uuid = UUID(scenario_id)
except ValueError:
raise NotFoundException("Scenario")
scenario = await scenario_repository.get(db, scenario_uuid)
if not scenario:
raise NotFoundException("Scenario")
if scenario.status != ScenarioStatus.RUNNING.value:
raise ScenarioNotRunningException()
return scenario

12
src/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""API v1 routes."""
from fastapi import APIRouter
from src.api.v1.scenarios import router as scenarios_router
from src.api.v1.ingest import router as ingest_router
from src.api.v1.metrics import router as metrics_router
api_router = APIRouter()
api_router.include_router(scenarios_router, prefix="/scenarios", tags=["scenarios"])
api_router.include_router(ingest_router, tags=["ingest"])
api_router.include_router(metrics_router, prefix="/scenarios", tags=["metrics"])

50
src/api/v1/ingest.py Normal file
View File

@@ -0,0 +1,50 @@
"""Ingest API endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, Header, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.deps import get_db, get_running_scenario
from src.schemas.log import LogIngest, LogResponse
from src.services.ingest_service import ingest_service
from src.models.scenario import Scenario
router = APIRouter()
@router.post(
"/ingest", response_model=LogResponse, status_code=status.HTTP_202_ACCEPTED
)
async def ingest_log(
log_data: LogIngest,
scenario: Scenario = Depends(get_running_scenario),
db: AsyncSession = Depends(get_db),
):
"""
Ingest a log message for processing.
- **message**: The log message content
- **source**: Optional source identifier
- **X-Scenario-ID**: Header with the scenario UUID
"""
log_entry = await ingest_service.ingest_log(
db=db, scenario=scenario, message=log_data.message, source=log_data.source
)
return LogResponse(
id=log_entry.id,
scenario_id=log_entry.scenario_id,
received_at=log_entry.received_at,
message_preview=log_entry.message_preview,
source=log_entry.source,
size_bytes=log_entry.size_bytes,
has_pii=log_entry.has_pii,
token_count=log_entry.token_count,
sqs_blocks=log_entry.sqs_blocks,
)
@router.post("/flush")
async def flush_queue():
"""Force immediate processing of queued messages."""
return {"status": "flushed", "message": "Processing is now synchronous"}

113
src/api/v1/metrics.py Normal file
View File

@@ -0,0 +1,113 @@
"""Metrics API endpoints."""
from uuid import UUID
from decimal import Decimal
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from src.api.deps import get_db
from src.repositories.scenario import scenario_repository
from src.schemas.metric import (
MetricsResponse,
MetricSummary,
CostBreakdown,
TimeseriesPoint,
)
from src.core.exceptions import NotFoundException
from src.services.cost_calculator import cost_calculator
from src.models.scenario_log import ScenarioLog
router = APIRouter()
@router.get("/{scenario_id}/metrics", response_model=MetricsResponse)
async def get_scenario_metrics(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get aggregated metrics for a scenario."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
# Get summary metrics
summary_result = await db.execute(
select(
func.count(ScenarioLog.id).label("total_logs"),
func.sum(ScenarioLog.sqs_blocks).label("total_sqs_blocks"),
func.sum(ScenarioLog.token_count).label("total_tokens"),
func.count(ScenarioLog.id)
.filter(ScenarioLog.has_pii == True)
.label("pii_violations"),
).where(ScenarioLog.scenario_id == scenario_id)
)
summary_row = summary_result.one()
# Calculate costs
region = scenario.region
sqs_cost = await cost_calculator.calculate_sqs_cost(
db, summary_row.total_sqs_blocks or 0, region
)
lambda_invocations = (summary_row.total_logs or 0) // 100 + 1
lambda_cost = await cost_calculator.calculate_lambda_cost(
db, lambda_invocations, 1.0, region
)
bedrock_cost = await cost_calculator.calculate_bedrock_cost(
db, summary_row.total_tokens or 0, 0, region
)
total_cost = sqs_cost + lambda_cost + bedrock_cost
cost_breakdown = [
CostBreakdown(
service="SQS",
cost_usd=sqs_cost,
percentage=float(sqs_cost / total_cost * 100) if total_cost > 0 else 0,
),
CostBreakdown(
service="Lambda",
cost_usd=lambda_cost,
percentage=float(lambda_cost / total_cost * 100) if total_cost > 0 else 0,
),
CostBreakdown(
service="Bedrock",
cost_usd=bedrock_cost,
percentage=float(bedrock_cost / total_cost * 100) if total_cost > 0 else 0,
),
]
summary = MetricSummary(
total_requests=scenario.total_requests,
total_cost_usd=total_cost,
sqs_blocks=summary_row.total_sqs_blocks or 0,
lambda_invocations=lambda_invocations,
llm_tokens=summary_row.total_tokens or 0,
pii_violations=summary_row.pii_violations or 0,
)
# Get timeseries data
timeseries_result = await db.execute(
select(
func.date_trunc("hour", ScenarioLog.received_at).label("hour"),
func.count(ScenarioLog.id).label("count"),
)
.where(ScenarioLog.scenario_id == scenario_id)
.group_by(func.date_trunc("hour", ScenarioLog.received_at))
.order_by(func.date_trunc("hour", ScenarioLog.received_at))
)
timeseries = [
TimeseriesPoint(
timestamp=row.hour, metric_type="requests", value=Decimal(row.count)
)
for row in timeseries_result.all()
]
return MetricsResponse(
scenario_id=scenario_id,
summary=summary,
cost_breakdown=cost_breakdown,
timeseries=timeseries,
)

171
src/api/v1/scenarios.py Normal file
View File

@@ -0,0 +1,171 @@
"""Scenario API endpoints."""
from uuid import UUID
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.deps import get_db
from src.repositories.scenario import scenario_repository, ScenarioStatus
from src.schemas.scenario import (
ScenarioCreate,
ScenarioUpdate,
ScenarioResponse,
ScenarioList,
)
from src.core.exceptions import NotFoundException, ValidationException
from src.core.config import settings
router = APIRouter()
@router.post("", response_model=ScenarioResponse, status_code=status.HTTP_201_CREATED)
async def create_scenario(
scenario_in: ScenarioCreate, db: AsyncSession = Depends(get_db)
):
"""Create a new scenario."""
existing = await scenario_repository.get_by_name(db, scenario_in.name)
if existing:
raise ValidationException(
f"Scenario with name '{scenario_in.name}' already exists"
)
scenario = await scenario_repository.create(db, obj_in=scenario_in.model_dump())
return scenario
@router.get("", response_model=ScenarioList)
async def list_scenarios(
status: str = Query(None, description="Filter by status"),
region: str = Query(None, description="Filter by region"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
settings.default_page_size,
ge=1,
le=settings.max_page_size,
description="Items per page",
),
db: AsyncSession = Depends(get_db),
):
"""List scenarios with optional filtering."""
skip = (page - 1) * page_size
filters = {}
if status:
filters["status"] = status
if region:
filters["region"] = region
scenarios = await scenario_repository.get_multi(
db, skip=skip, limit=page_size, **filters
)
total = await scenario_repository.count(db, **filters)
return ScenarioList(items=scenarios, total=total, page=page, page_size=page_size)
@router.get("/{scenario_id}", response_model=ScenarioResponse)
async def get_scenario(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get a specific scenario by ID."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
return scenario
@router.put("/{scenario_id}", response_model=ScenarioResponse)
async def update_scenario(
scenario_id: UUID, scenario_in: ScenarioUpdate, db: AsyncSession = Depends(get_db)
):
"""Update a scenario."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
if scenario_in.name and scenario_in.name != scenario.name:
existing = await scenario_repository.get_by_name(db, scenario_in.name)
if existing:
raise ValidationException(
f"Scenario with name '{scenario_in.name}' already exists"
)
updated = await scenario_repository.update(
db, db_obj=scenario, obj_in=scenario_in.model_dump(exclude_unset=True)
)
return updated
@router.delete("/{scenario_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scenario(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Delete a scenario."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
await scenario_repository.delete(db, id=scenario_id)
return None
@router.post("/{scenario_id}/start", response_model=ScenarioResponse)
async def start_scenario(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Start a scenario (draft -> running)."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
if scenario.status != ScenarioStatus.DRAFT.value:
raise ValidationException(
f"Cannot start scenario with status '{scenario.status}'"
)
await scenario_repository.update(
db,
db_obj=scenario,
obj_in={
"status": ScenarioStatus.RUNNING.value,
"started_at": datetime.utcnow(),
},
)
await db.refresh(scenario)
return scenario
@router.post("/{scenario_id}/stop", response_model=ScenarioResponse)
async def stop_scenario(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Stop a scenario (running -> completed)."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
if scenario.status != ScenarioStatus.RUNNING.value:
raise ValidationException(
f"Cannot stop scenario with status '{scenario.status}'"
)
await scenario_repository.update(
db,
db_obj=scenario,
obj_in={
"status": ScenarioStatus.COMPLETED.value,
"completed_at": datetime.utcnow(),
},
)
await db.refresh(scenario)
return scenario
@router.post("/{scenario_id}/archive", response_model=ScenarioResponse)
async def archive_scenario(scenario_id: UUID, db: AsyncSession = Depends(get_db)):
"""Archive a scenario (completed -> archived)."""
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
if scenario.status != ScenarioStatus.COMPLETED.value:
raise ValidationException(
f"Cannot archive scenario with status '{scenario.status}'"
)
await scenario_repository.update_status(db, scenario_id, ScenarioStatus.ARCHIVED)
await db.refresh(scenario)
return scenario