Coverage for src/case_router/api.py: 100%
155 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
1"""HTTP API. Every /v1 request names its tenant in the X-Tenant-Id header."""
3from __future__ import annotations
5import math
6import os
7import time
8from collections.abc import Awaitable, Callable
9from dataclasses import dataclass
10from datetime import datetime
11from typing import Annotated, Literal, cast
13from fastapi import (
14 APIRouter,
15 Body,
16 Depends,
17 FastAPI,
18 Header,
19 HTTPException,
20 Query,
21 Request,
22 Response,
23)
24from fastapi.responses import JSONResponse
25from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
26from pydantic import BaseModel, ConfigDict, Field
28from . import __version__
29from .db import Database, RuleRow
30from .errors import CaseAlreadyClosed, CaseNotFound, UnknownTenant
31from .limits import Clock, TenantLimiter
32from .metrics import Metrics
33from .models import Case, CaseDraft, Channel, Priority, QueueHealth, Status, Tenant, Tier
34from .seed import seed_demo_data
35from .service import CaseService, UtcClock, utc_now
38@dataclass(frozen=True)
39class Settings:
40 database_path: str = "case_router.db"
41 seed_demo_data: bool = True
43 @classmethod
44 def from_env(cls) -> Settings:
45 return cls(
46 database_path=os.environ.get("CASE_ROUTER_DB", cls.database_path),
47 seed_demo_data=os.environ.get("CASE_ROUTER_SEED", "1") != "0",
48 )
51# Control characters have no place in a case and can forge extra lines in logs. The
52# description may still hold tabs and line breaks.
53NO_CONTROL_CHARS = r"^[^\x00-\x1f\x7f]*$"
54NO_CONTROL_CHARS_EXCEPT_LINES = r"^[^\x00-\x08\x0b\x0c\x0e-\x1f\x7f]*$"
57class CaseIn(BaseModel):
58 model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
60 subject: str = Field(min_length=3, max_length=200, pattern=NO_CONTROL_CHARS)
61 description: str = Field(default="", max_length=5000, pattern=NO_CONTROL_CHARS_EXCEPT_LINES)
62 priority: Priority = Priority.MEDIUM
63 channel: Channel = Channel.WEB
64 tier: Tier = Tier.STANDARD
66 def to_draft(self) -> CaseDraft:
67 return CaseDraft(self.subject, self.description, self.priority, self.channel, self.tier)
70class CaseOut(BaseModel):
71 id: int
72 subject: str
73 description: str
74 priority: Priority
75 channel: Channel
76 tier: Tier
77 queue: str
78 rule: str
79 status: Status
80 created_at: datetime
81 respond_by: datetime
82 closed_at: datetime | None
84 @classmethod
85 def of(cls, case: Case) -> CaseOut:
86 return cls(
87 id=case.id,
88 subject=case.subject,
89 description=case.description,
90 priority=case.priority,
91 channel=case.channel,
92 tier=case.tier,
93 queue=case.queue,
94 rule=case.rule,
95 status=case.status,
96 created_at=case.created_at,
97 respond_by=case.respond_by,
98 closed_at=case.closed_at,
99 )
102class QueueHealthOut(BaseModel):
103 queue: str
104 open_cases: int
105 overdue_cases: int
106 oldest_open_at: datetime | None
108 @classmethod
109 def of(cls, health: QueueHealth) -> QueueHealthOut:
110 return cls(
111 queue=health.queue,
112 open_cases=health.open_cases,
113 overdue_cases=health.overdue_cases,
114 oldest_open_at=health.oldest_open_at,
115 )
118class RuleIn(BaseModel):
119 model_config = ConfigDict(extra="forbid")
121 kind: Literal["priority", "tier", "channel", "keyword", "any"]
122 value: str = Field(default="", max_length=500)
123 queue: str = Field(min_length=1, max_length=100)
126class RuleOut(BaseModel):
127 position: int
128 name: str
129 queue: str
132@dataclass(frozen=True)
133class AppState:
134 db: Database
135 service: CaseService
136 limiter: TenantLimiter
137 metrics: Metrics
140def app_state(request: Request) -> AppState:
141 return cast(AppState, request.app.state.case_router)
144StateDep = Annotated[AppState, Depends(app_state)]
147def current_tenant(
148 state: StateDep, x_tenant_id: Annotated[str, Header(min_length=1, max_length=64)]
149) -> Tenant:
150 """Look up the tenant and spend one token from its request allowance."""
151 tenant = state.service.tenant(x_tenant_id)
152 acquisition = state.limiter.acquire(tenant)
153 if not acquisition.allowed:
154 state.metrics.requests_rejected.labels(tenant.id, "rate_limit").inc()
155 raise HTTPException(
156 status_code=429,
157 detail="Request limit reached for this tenant",
158 headers={"Retry-After": str(max(1, math.ceil(acquisition.retry_after)))},
159 )
160 return tenant
163TenantDep = Annotated[Tenant, Depends(current_tenant)]
165ops = APIRouter(tags=["ops"])
166v1 = APIRouter(prefix="/v1")
169@ops.get("/healthz")
170def health(state: StateDep) -> dict[str, str]:
171 with state.db.connect() as conn:
172 conn.execute("SELECT 1")
173 return {"status": "ok"}
176@ops.get("/metrics")
177def prometheus_metrics(state: StateDep) -> Response:
178 return Response(generate_latest(state.metrics.registry), media_type=CONTENT_TYPE_LATEST)
181@v1.post("/cases", status_code=201, tags=["cases"])
182def submit_case(body: CaseIn, tenant: TenantDep, state: StateDep) -> CaseOut:
183 return CaseOut.of(state.service.submit(tenant, body.to_draft()))
186@v1.get("/cases", tags=["cases"])
187def list_cases(
188 tenant: TenantDep,
189 state: StateDep,
190 queue: str | None = None,
191 status: Status | None = None,
192 limit: Annotated[int, Query(ge=1, le=200)] = 50,
193) -> list[CaseOut]:
194 cases = state.service.find(tenant, queue=queue, status=status, limit=limit)
195 return [CaseOut.of(c) for c in cases]
198@v1.get("/cases/{case_id}", tags=["cases"])
199def get_case(case_id: int, tenant: TenantDep, state: StateDep) -> CaseOut:
200 return CaseOut.of(state.service.get(tenant, case_id))
203@v1.post("/cases/{case_id}/close", tags=["cases"])
204def close_case(case_id: int, tenant: TenantDep, state: StateDep) -> CaseOut:
205 return CaseOut.of(state.service.close(tenant, case_id))
208@v1.get("/queues", tags=["queues"])
209def queue_health(tenant: TenantDep, state: StateDep) -> list[QueueHealthOut]:
210 return [QueueHealthOut.of(q) for q in state.service.queue_health(tenant)]
213def _rules_out(state: AppState, tenant: Tenant) -> list[RuleOut]:
214 rules = state.service.rule_set(tenant.id)
215 return [RuleOut(position=i, name=r.name, queue=r.queue) for i, r in enumerate(rules, 1)]
218@v1.get("/rules", tags=["rules"])
219def get_rules(tenant: TenantDep, state: StateDep) -> list[RuleOut]:
220 return _rules_out(state, tenant)
223@v1.put("/rules", tags=["rules"])
224def replace_rules(
225 body: Annotated[list[RuleIn], Body(min_length=1, max_length=50)],
226 tenant: TenantDep,
227 state: StateDep,
228) -> list[RuleOut]:
229 try:
230 state.service.rules.replace(tenant.id, [RuleRow(r.kind, r.value, r.queue) for r in body])
231 except ValueError as exc:
232 raise HTTPException(status_code=422, detail=str(exc)) from exc
233 return _rules_out(state, tenant)
236async def _unknown_tenant(_request: Request, _exc: Exception) -> JSONResponse:
237 return JSONResponse({"detail": "Unknown tenant"}, status_code=404)
240async def _case_not_found(_request: Request, _exc: Exception) -> JSONResponse:
241 return JSONResponse({"detail": "Case not found"}, status_code=404)
244async def _already_closed(_request: Request, _exc: Exception) -> JSONResponse:
245 return JSONResponse({"detail": "Case is already closed"}, status_code=409)
248def create_app(
249 settings: Settings | None = None,
250 *,
251 clock: UtcClock = utc_now,
252 monotonic: Clock = time.monotonic,
253) -> FastAPI:
254 """Build an app with its own database, limiter, and metrics registry.
256 Clocks are injected so tests can move time forward instead of sleeping.
257 """
258 settings = settings or Settings.from_env()
259 db = Database(settings.database_path)
260 if settings.seed_demo_data:
261 seed_demo_data(db)
262 metrics = Metrics()
263 state = AppState(
264 db=db,
265 service=CaseService(db, metrics=metrics, clock=clock),
266 limiter=TenantLimiter(clock=monotonic),
267 metrics=metrics,
268 )
270 app = FastAPI(
271 title="Case Router",
272 version=__version__,
273 description="Routes support cases to queues per tenant, with request limits and metrics.",
274 )
275 app.state.case_router = state
276 app.include_router(ops)
277 app.include_router(v1)
278 app.add_exception_handler(UnknownTenant, _unknown_tenant)
279 app.add_exception_handler(CaseNotFound, _case_not_found)
280 app.add_exception_handler(CaseAlreadyClosed, _already_closed)
282 @app.middleware("http")
283 async def record_latency(
284 request: Request, call_next: Callable[[Request], Awaitable[Response]]
285 ) -> Response:
286 started = time.perf_counter()
287 response = await call_next(request)
288 route = getattr(request.scope.get("route"), "path", "unmatched")
289 metrics.request_seconds.labels(request.method, route, str(response.status_code)).observe(
290 time.perf_counter() - started
291 )
292 return response
294 return app