{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 4 — Actuarial APIs with FastAPI\n",
    "\n",
    "**Companion notebook** for the Lesson 4 slides. You'll build a small rating service:\n",
    "request and response schemas, a `POST /quotes` endpoint, an API-key dependency, and a\n",
    "batch endpoint, then test it with FastAPI's `TestClient` (no server needed) and see\n",
    "what `async` does and doesn't buy you."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import asyncio\n",
    "import secrets\n",
    "import time\n",
    "from typing import Annotated, Literal\n",
    "\n",
    "import httpx\n",
    "from fastapi import Depends, FastAPI, Header, HTTPException, status\n",
    "from fastapi.testclient import TestClient\n",
    "from pydantic import BaseModel, Field\n",
    "\n",
    "BASE = {\"LIAB\": 938.09, \"COLL\": 576.85}\n",
    "STYLE = {\"Sedan\": 1.00, \"SUV\": 1.05, \"Hatchback\": 1.10, \"Pickup\": 0.90, \"Minivan\": 0.85, \"Sports\": 1.40}\n",
    "AGE = [(25, 1.60), (30, 1.15), (65, 1.00), (75, 1.10), (200, 1.35)]\n",
    "MILES = [(7_500, 0.85), (15_000, 1.00), (float(\"inf\"), 1.20)]\n",
    "DEDUCTIBLE = {250: 1.10, 500: 1.00, 1000: 0.88}\n",
    "\n",
    "\n",
    "def band(value, table):\n",
    "    return next(f for upper, f in table if value < upper)\n",
    "\n",
    "\n",
    "def rate(style, age, miles, value=None, deductible=None):\n",
    "    \"\"\"Manual LIAB premium, or COLL when a value and deductible are given.\"\"\"\n",
    "    common = STYLE[style] * band(age, AGE) * band(miles, MILES)\n",
    "    if deductible is None:\n",
    "        return round(BASE[\"LIAB\"] * common, 2)\n",
    "    return round(BASE[\"COLL\"] * common * value / 30_000 * DEDUCTIBLE[deductible], 2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "---\n",
    "## Your turn 1 — the contract\n",
    "\n",
    "Write the request schema `QuoteRequest`:\n",
    "- `driver_age`: number, 16 to 110\n",
    "- `vehicle_style`: one of the six styles (a `Literal`)\n",
    "- `vehicle_value`: greater than 0\n",
    "- `annual_mileage`: greater than 0, at most 150,000\n",
    "- `coll_deductible`: `Literal[250, 500, 1000]` or `None` (no collision cover)\n",
    "\n",
    "and forbid unknown fields (`model_config = {\"extra\": \"forbid\"}`), so a typo like\n",
    "`drivr_age` is an error rather than silently ignored."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "Style = Literal[\"Sedan\", \"SUV\", \"Hatchback\", \"Pickup\", \"Minivan\", \"Sports\"]\n",
    "\n",
    "...  # your code here\n",
    "\n",
    "\n",
    "class QuoteResponse(BaseModel):\n",
    "    quote_id: int\n",
    "    premiums: dict[str, float]\n",
    "    total_premium: float"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "## Your turn 2 — the endpoint\n",
    "\n",
    "Complete `create_app()`: `POST /quotes` takes a `QuoteRequest` and returns a\n",
    "`QuoteResponse` with status **201**, LIAB always, COLL only when a deductible is given,\n",
    "and a `quote_id` that counts up from 1. Keep the quotes in the `QUOTES` dict so that\n",
    "`GET /quotes/{quote_id}` can return them (404 when unknown)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "def create_app(api_key: str | None = None) -> FastAPI:\n",
    "    app = FastAPI(title=\"Motor Rating API (notebook)\")\n",
    "    QUOTES: dict[int, QuoteResponse] = {}\n",
    "\n",
    "    def require_key(x_api_key: Annotated[str | None, Header()] = None):\n",
    "        if api_key is not None and (x_api_key is None or not secrets.compare_digest(x_api_key, api_key)):\n",
    "            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"missing or invalid X-API-Key\")\n",
    "\n",
    "    ...  # your code here\n",
    "\n",
    "    return app\n",
    "\n",
    "\n",
    "BODY = {\"driver_age\": 22, \"vehicle_style\": \"Sports\", \"vehicle_value\": 45_000, \"annual_mileage\": 16_000,\n",
    "        \"coll_deductible\": 1000}\n",
    "client = TestClient(create_app())\n",
    "r = client.post(\"/quotes\", json=BODY)\n",
    "print(r.status_code, r.json())\n",
    "check(\"status\", r.status_code, 201)\n",
    "premiums = r.json().get(\"premiums\", {})\n",
    "check(\"LIAB premium\", premiums.get(\"LIAB\"), 2521.59)\n",
    "check(\"COLL premium\", premiums.get(\"COLL\"), 2046.76)\n",
    "check(\"stored and retrievable\", r.status_code == 201 and client.get(\"/quotes/1\").json() == r.json(), True)\n",
    "check(\"unknown quote\", client.get(\"/quotes/99\").status_code, 404)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "## Your turn 3 — what clients see when they get it wrong\n",
    "\n",
    "Send each of these and store the list of status codes in `codes`. Look at the body of\n",
    "the 422 responses: FastAPI tells the client **which field** is wrong and why, for free."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "BAD = [\n",
    "    {**BODY, \"driver_age\": 15},                 # too young\n",
    "    {**BODY, \"vehicle_style\": \"Coupe\"},         # not a style we rate\n",
    "    {**BODY, \"coll_deductible\": 750},           # not a deductible we offer\n",
    "    {**BODY, \"drivr_age\": 30},                  # typo in a field name\n",
    "    {k: v for k, v in BODY.items() if k != \"annual_mileage\"},   # missing field\n",
    "]\n",
    "codes = None\n",
    "...  # your code here\n",
    "check(\"status codes\", codes, [422, 422, 422, 422, 422])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "## Your turn 4 — an API key\n",
    "\n",
    "With `create_app(api_key=\"s3cret\")`, a request without the `X-API-Key` header, or with a\n",
    "wrong one, must get **401**; the right key gets 201. Store the three status codes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "key_codes = None\n",
    "...  # your code here\n",
    "check(\"no key, wrong key, right key\", key_codes, [401, 401, 201])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-010",
   "metadata": {},
   "source": [
    "## 5. The contract, documented\n",
    "\n",
    "FastAPI writes an OpenAPI description of every endpoint and schema. It is what\n",
    "`/docs` renders, and what client code can be generated from."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-011",
   "metadata": {},
   "outputs": [],
   "source": [
    "spec = client.get(\"/openapi.json\").json()\n",
    "print(list(spec[\"paths\"]))\n",
    "print(spec.get(\"components\", {}).get(\"schemas\", {}).get(\"QuoteRequest\", {}).get(\"properties\", {}).get(\"driver_age\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-012",
   "metadata": {},
   "source": [
    "## Your turn 5 — async: waiting vs blocking\n",
    "\n",
    "A quote that calls another service (an address or claims-history lookup) spends its\n",
    "time **waiting**. Below are three versions of a 0.1-second lookup. Send 10 requests at\n",
    "once to each (`asyncio.gather` with an `httpx.AsyncClient` over `httpx.ASGITransport(app)`)\n",
    "and store the wall-clock seconds in `timings`, keyed by path.\n",
    "\n",
    "Predict first: which one takes about 1 second?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-013",
   "metadata": {},
   "outputs": [],
   "source": [
    "demo = FastAPI()\n",
    "\n",
    "\n",
    "@demo.get(\"/await\")\n",
    "async def awaits():\n",
    "    await asyncio.sleep(0.1)      # waits, letting other requests run\n",
    "    return {\"ok\": True}\n",
    "\n",
    "\n",
    "@demo.get(\"/blocking\")\n",
    "async def blocks():\n",
    "    time.sleep(0.1)               # blocks the event loop: every other request waits too\n",
    "    return {\"ok\": True}\n",
    "\n",
    "\n",
    "@demo.get(\"/thread\")\n",
    "def threaded():\n",
    "    time.sleep(0.1)               # plain def: FastAPI runs it in a worker thread\n",
    "    return {\"ok\": True}\n",
    "\n",
    "\n",
    "timings = None\n",
    "...  # your code here\n",
    "check(\"blocking is the slow one\", None if timings is None else max(timings, key=timings.get), \"/blocking\")\n",
    "check(\"awaiting overlaps the waits\", None if timings is None else timings[\"/await\"] < 0.5, True)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
