v1
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user