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:
15
src/services/__init__.py
Normal file
15
src/services/__init__.py
Normal 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",
|
||||
]
|
||||
86
src/services/cost_calculator.py
Normal file
86
src/services/cost_calculator.py
Normal 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()
|
||||
65
src/services/ingest_service.py
Normal file
65
src/services/ingest_service.py
Normal 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()
|
||||
53
src/services/pii_detector.py
Normal file
53
src/services/pii_detector.py
Normal 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()
|
||||
Reference in New Issue
Block a user