import re from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.auth.deps import get_current_user from app.database import get_db from app.middleware.rate_limit import limiter from app.schemas.experiments import ( ExperimentConversionCreate, ExperimentDefinitionResponse, ExperimentDefinitionUpdate, ExperimentEventCreate, ExperimentImpressionCreate, ExperimentIngestResponse, ExperimentResult, ) from app.services.experiments import ( experiment_exists, get_experiment_definition, get_experiment_results, list_experiment_definitions, record_experiment_event, upsert_experiment_definition, ) from app.services.settings import get_feature_settings_snapshot router = APIRouter(tags=["Experiments"]) BOT_UA_PATTERN = re.compile(r"(bot|crawler|spider|slurp|preview|headless)", re.IGNORECASE) def _is_bot_request(request: Request) -> bool: user_agent = request.headers.get("user-agent", "") return bool(BOT_UA_PATTERN.search(user_agent)) def _validate_experiment_assignment(experiment_key: str, variant_key: str) -> None: if not experiment_exists(experiment_key, variant_key): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="unknown experiment or variant", ) async def _experiments_enabled(db: AsyncSession) -> bool: feature_settings = await get_feature_settings_snapshot(db) return feature_settings.experiments_enabled async def _require_experiments_enabled(db: AsyncSession) -> None: if not await _experiments_enabled(db): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Experiments are currently disabled.") @router.get("/api/experiments", response_model=list[ExperimentDefinitionResponse]) async def get_experiments(db: AsyncSession = Depends(get_db)): if not await _experiments_enabled(db): return [] return await list_experiment_definitions(db) @router.post("/api/experiments/impression", response_model=ExperimentIngestResponse, status_code=202) @limiter.limit("30/minute") async def ingest_experiment_impression( request: Request, response: Response, payload: ExperimentImpressionCreate, db: AsyncSession = Depends(get_db), ): if not await _experiments_enabled(db): return ExperimentIngestResponse(ok=True, accepted=False) _validate_experiment_assignment(payload.experiment_key, payload.variant_key) if _is_bot_request(request): return ExperimentIngestResponse(ok=True, accepted=False) await record_experiment_event(db, payload) return ExperimentIngestResponse(ok=True, accepted=True) @router.post("/api/experiments/event", response_model=ExperimentIngestResponse, status_code=202) @limiter.limit("30/minute") async def ingest_experiment_event( request: Request, response: Response, payload: ExperimentEventCreate, db: AsyncSession = Depends(get_db), ): if not await _experiments_enabled(db): return ExperimentIngestResponse(ok=True, accepted=False) _validate_experiment_assignment(payload.experiment_key, payload.variant_key) if _is_bot_request(request): return ExperimentIngestResponse(ok=True, accepted=False) await record_experiment_event(db, payload) return ExperimentIngestResponse(ok=True, accepted=True) @router.post("/api/experiments/conversion", response_model=ExperimentIngestResponse, status_code=202) @limiter.limit("30/minute") async def ingest_experiment_conversion( request: Request, response: Response, payload: ExperimentConversionCreate, db: AsyncSession = Depends(get_db), ): if not await _experiments_enabled(db): return ExperimentIngestResponse(ok=True, accepted=False) _validate_experiment_assignment(payload.experiment_key, payload.variant_key) if _is_bot_request(request): return ExperimentIngestResponse(ok=True, accepted=False) await record_experiment_event(db, payload) return ExperimentIngestResponse(ok=True, accepted=True) @router.get("/api/v1/experiments/results", response_model=list[ExperimentResult]) async def experiment_results( experiment_key: str | None = None, db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ): await _require_experiments_enabled(db) return await get_experiment_results(db, experiment_key) @router.get("/api/admin/experiments", response_model=list[ExperimentDefinitionResponse]) async def admin_list_experiments( db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ): await _require_experiments_enabled(db) return await list_experiment_definitions(db) @router.get("/api/admin/experiments/{experiment_key}", response_model=ExperimentDefinitionResponse) async def admin_get_experiment( experiment_key: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ): await _require_experiments_enabled(db) experiment = await get_experiment_definition(db, experiment_key) if experiment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Experiment not found") definitions = await list_experiment_definitions(db) match = next((item for item in definitions if item.experiment_key == experiment_key), None) assert match is not None return match @router.put("/api/admin/experiments/{experiment_key}", response_model=ExperimentDefinitionResponse) async def admin_update_experiment( experiment_key: str, payload: ExperimentDefinitionUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ): await _require_experiments_enabled(db) try: experiment = await upsert_experiment_definition(db, experiment_key, payload) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return ExperimentDefinitionResponse( experiment_key=experiment.experiment_key, cookie_name=experiment.cookie_name, name=experiment.name, description=experiment.description, enabled=experiment.enabled, eligible_routes=experiment.eligible_routes, variants=[ { "variant_key": variant.variant_key, "label": variant.label, "allocation": variant.allocation, "is_control": variant.is_control, } for variant in experiment.variants ], )