From 8b7d92356a2b8381f9b5fc1a573862604b45c9ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:02:08 +0000 Subject: [PATCH] Declare response models for every route; add CI; drop httpx - fastapi.py: OfferCreated, Claimed, OfferStatus, Collected and LinkedDeviceOut response models, plus a text/event-stream response for /events. Apps' OpenAPI specs now describe pairing responses exactly, so clients (like ScribbleScan-web) can generate their types instead of writing them by hand. The JSON is unchanged: qr_url is still left out without a scan_url, and Collected keeps whatever the app put in Grant.data. - Tests: every route has a response schema; no qr_url without a scan_url (20 tests). - CI: ruff and pytest on Python 3.12 and 3.14. - Dev dependencies no longer include httpx, which is stalled; FastAPI's TestClient already uses httpx2. - Roadmap: a Postgres store (LISTEN/NOTIFY) instead of Redis, for apps that already run Postgres. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5s1B8CvD6MoPSJZpL8ScT --- .github/workflows/ci.yml | 26 +++++++++++++++ CLAUDE.md | 5 +-- README.md | 6 ++-- pyproject.toml | 1 - src/pairkit/fastapi.py | 72 ++++++++++++++++++++++++++++++++++------ tests/test_fastapi.py | 22 ++++++++++++ uv.lock | 39 ---------------------- 7 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2b1e7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Tests and lint (Python ${{ matrix.python }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + # The oldest Python pairkit supports, and the newest. + python: ["3.12", "3.14"] + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python }} + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run pytest -q diff --git a/CLAUDE.md b/CLAUDE.md index ca026e8..b046f20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,14 @@ Reusable QR-code device pairing, extracted from [ScribbleScan](https://github.co - **The core stays framework-free and auth-agnostic.** The app's auth is touched only through `PairingHooks.issue_grant` / `revoke_grant`. Framework code lives in adapters (`fastapi.py`). - **Security properties are the product; keep every one** (each has a test): single-use secrets that expire after 2 minutes, stored hashed; the secret goes in the URL fragment; per-side private keys (`shower_key`, `claimer_key`), so a photographed QR is useless after the real scan; approval only by the side that has access; compare-and-set state changes (`Store.swap`); uncollected grants revoked on expiry; rate-limited claims. - `MemoryStore` and the in-process `EventBus` are for tests and single-process apps. Production stores (Redis, Postgres) must implement `swap` as a real compare-and-set on `version`. +- Every route in `fastapi.py` declares a response model (apps generate client types from the OpenAPI spec); `test_openapi_describes_every_response` enforces it. `Collected` allows extra fields because apps put their own data in `Grant.data`. - Don't add `from __future__ import annotations` to `fastapi.py`: FastAPI can't resolve the `Annotated` aliases defined inside `pairing_router`. ## Commands ```bash uv sync -uv run pytest # 18 tests: both flows, permissions, replay, races, expiry, SSE, HTTP API +uv run pytest # 20 tests: both flows, permissions, replay, races, expiry, SSE, HTTP API, OpenAPI contract uv run ruff check . uv run --with "fastapi[standard]" --with segno python examples/camera_demo.py # phone-as-camera demo on your Wi-Fi ``` @@ -25,4 +26,4 @@ ScribbleScan installs pairkit straight from this repo (`[tool.uv.sources]`), so ## Roadmap -Redis store and event bus · `@pairkit/client` JS package (QR display, waiting state, phone helpers) · Django adapter · publish to PyPI. +Postgres store and event bus (LISTEN/NOTIFY; ScribbleScan already runs Postgres) · `@pairkit/client` JS package (QR display, waiting state, phone helpers) · Django adapter · publish to PyPI. diff --git a/README.md b/README.md index 724b23c..902a567 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ app.include_router(pairing_router(service, current_user, scan_url="https://app.e | `POST /pair/offers/{id}/collect` | Receiving device: picks up its token once | | `GET /pair/devices` · `DELETE /pair/devices/{id}` | User: list and unlink devices | +Every endpoint declares its response model, so your app's OpenAPI spec describes them exactly and client types can be generated from it. + The core (`PairingService`) has no web framework dependency; other adapters can wrap it the same way. ## Try the demo @@ -77,7 +79,7 @@ Open the printed address on your PC, scan the QR with your phone's camera, click ```bash uv sync -uv run pytest # flows, permissions, replay, races, expiry, live events, HTTP API +uv run pytest # flows, permissions, replay, races, expiry, live events, HTTP API, OpenAPI contract uv run ruff check . ``` @@ -87,6 +89,6 @@ uv run ruff check . ## Roadmap -- Redis store and event bus +- A Postgres store and event bus (LISTEN/NOTIFY), for apps that already run Postgres and need several server processes - `@pairkit/client` JavaScript package: QR display, waiting state, phone-side helpers - Django adapter diff --git a/pyproject.toml b/pyproject.toml index 79a05e4..e4e68be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ dev = [ "pytest-asyncio>=0.24", "ruff>=0.6", "fastapi>=0.110", - "httpx>=0.27", "httpx2>=2.13.1", ] diff --git a/src/pairkit/fastapi.py b/src/pairkit/fastapi.py index c4e0c9e..c0f749c 100644 --- a/src/pairkit/fastapi.py +++ b/src/pairkit/fastapi.py @@ -24,9 +24,9 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field -from .models import DeviceInfo, GrantTo, LinkedDevice +from .models import DeviceInfo, GrantTo, LinkedDevice, State from .service import OfferView, PairingError, PairingService STATUS = { @@ -53,6 +53,54 @@ class ClaimIn(BaseModel): device_label: str = Field("", max_length=100) +# Responses. Declared so the OpenAPI spec describes exactly what clients receive, and +# apps can generate client types from it. Times are ISO 8601 strings. + + +class OfferCreated(BaseModel): + offer_id: str + secret: str = Field(description="goes in the QR code, in the URL fragment") + shower_key: str = Field(description="this device's private key: X-Pairkit-Key / ?key=") + expires_at: str + qr_url: str | None = Field(None, description="ready-to-encode scan link, when the router has a scan_url") + + +class Claimed(BaseModel): + offer_id: str + claimer_key: str = Field(description="the scanning device's private key: X-Pairkit-Key / ?key=") + grant_to: GrantTo + scopes: list[str] + shower: str = Field(description="how the device showing the QR describes itself") + expires_at: str + + +class OfferStatus(BaseModel): + offer_id: str + state: State + grant_to: GrantTo + scopes: list[str] + expires_at: str + shower: str + scanner: str | None + + +class Collected(BaseModel): + """The grant from the app's issue_grant hook, plus whatever it put in Grant.data.""" + + model_config = ConfigDict(extra="allow") + token: str + expires_at: str | None + + +class LinkedDeviceOut(BaseModel): + id: str + device: str + scopes: list[str] + linked_at: str + via: GrantTo + active: bool + + def _device(request: Request, label: str) -> DeviceInfo: return DeviceInfo( label=label, @@ -108,7 +156,7 @@ def required(user: OptionalUser) -> str: User = Annotated[str, Depends(required)] Key = Annotated[str, Header(alias="X-Pairkit-Key")] - @router.post("/offers", status_code=201) + @router.post("/offers", status_code=201, response_model=OfferCreated, response_model_exclude_none=True) async def create_offer(body: CreateOfferIn, request: Request, user: OptionalUser): try: o = await service.create_offer(body.grant_to, body.scopes, _device(request, body.device_label), user) @@ -120,7 +168,7 @@ async def create_offer(body: CreateOfferIn, request: Request, user: OptionalUser out["qr_url"] = o.qr_payload(scan_url) return out - @router.post("/claim") + @router.post("/claim", response_model=Claimed) async def claim(body: ClaimIn, request: Request, user: OptionalUser): device = _device(request, body.device_label) try: @@ -130,14 +178,16 @@ async def claim(body: ClaimIn, request: Request, user: OptionalUser): return {"offer_id": c.offer_id, "claimer_key": c.claimer_key, "grant_to": c.grant_to.value, "scopes": list(c.scopes), "shower": c.shower.describe(), "expires_at": c.expires_at.isoformat()} - @router.get("/offers/{offer_id}") + @router.get("/offers/{offer_id}", response_model=OfferStatus) async def status(offer_id: str, key: Key): try: return _view(await service.status(offer_id, key)) except PairingError as e: raise _fail(e) from None - @router.get("/offers/{offer_id}/events") + @router.get("/offers/{offer_id}/events", response_class=StreamingResponse, + responses={200: {"content": {"text/event-stream": {}}, + "description": "Server-sent events: state, claimed, approved, denied, collected, expired"}}) async def events(offer_id: str, key: Annotated[str, Query()]): try: await service.status(offer_id, key) # fail fast with a proper status code @@ -151,21 +201,21 @@ async def stream(): return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}) - @router.post("/offers/{offer_id}/approve") + @router.post("/offers/{offer_id}/approve", response_model=OfferStatus) async def approve(offer_id: str, user: User): try: return _view(await service.approve(offer_id, user)) except PairingError as e: raise _fail(e) from None - @router.post("/offers/{offer_id}/deny") + @router.post("/offers/{offer_id}/deny", response_model=OfferStatus) async def deny(offer_id: str, user: User): try: return _view(await service.deny(offer_id, user)) except PairingError as e: raise _fail(e) from None - @router.post("/offers/{offer_id}/collect") + @router.post("/offers/{offer_id}/collect", response_model=Collected) async def collect(offer_id: str, key: Key): try: g = await service.collect(offer_id, key) @@ -173,11 +223,11 @@ async def collect(offer_id: str, key: Key): raise _fail(e) from None return {"token": g.token, "expires_at": g.expires_at.isoformat() if g.expires_at else None, **g.data} - @router.get("/devices") + @router.get("/devices", response_model=list[LinkedDeviceOut]) async def devices(user: User): return [_linked(d) for d in await service.list_devices(user)] - @router.delete("/devices/{device_id}") + @router.delete("/devices/{device_id}", response_model=LinkedDeviceOut) async def unlink(device_id: str, user: User): try: return _linked(await service.revoke_device(device_id, user)) diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 5e7715d..2be3a28 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -74,3 +74,25 @@ def test_error_codes(client): assert second.status_code == 409 and second.json()["detail"]["code"] == "already_used" assert client.post(f"/pair/offers/{offer['offer_id']}/approve").status_code == 401 assert client.get(f"/pair/offers/{offer['offer_id']}", headers={"X-Pairkit-Key": "nope"}).status_code == 404 + + +def test_openapi_describes_every_response(client): + """Apps generate client types from the OpenAPI spec, so every JSON response needs a schema.""" + spec = client.get("/openapi.json").json() + for path, methods in spec["paths"].items(): + for method, op in methods.items(): + content = op["responses"][next(c for c in op["responses"] if c.startswith("2"))]["content"] + if path.endswith("/events"): + assert "text/event-stream" in content + else: + assert content["application/json"]["schema"], f"{method.upper()} {path}" + created = spec["components"]["schemas"]["OfferCreated"] + assert {"offer_id", "secret", "shower_key", "expires_at"} <= set(created["required"]) + + +def test_qr_url_only_when_the_router_has_a_scan_url(): + app = FastAPI() + app.include_router(pairing_router(PairingService(PairingHooks(issue)), current_user)) + offer = TestClient(app).post("/pair/offers", json={"grant_to": "scanner", "scopes": ["upload"]}, + headers=ALMAS).json() + assert "qr_url" not in offer and offer["secret"] diff --git a/uv.lock b/uv.lock index 4f11d8c..25ef48b 100644 --- a/uv.lock +++ b/uv.lock @@ -33,15 +33,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -77,19 +68,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - [[package]] name = "httpcore2" version = "2.13.1" @@ -103,21 +81,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "httpx2" version = "2.13.1" @@ -196,7 +159,6 @@ fastapi = [ [package.dev-dependencies] dev = [ { name = "fastapi" }, - { name = "httpx" }, { name = "httpx2" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -210,7 +172,6 @@ provides-extras = ["fastapi"] [package.metadata.requires-dev] dev = [ { name = "fastapi", specifier = ">=0.110" }, - { name = "httpx", specifier = ">=0.27" }, { name = "httpx2", specifier = ">=2.13.1" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.24" },