# Brew & Bean orders API

The capstone project of the FastAPI course: an order-management service for a chain of coffee bars,
built the way a senior team would build it. It is lesson 25 of the course.

| Concern | How it is handled | Lesson |
|---|---|---|
| Structure | app factory, routers, services, lifespan-managed resources | 07, 10 |
| Settings | `pydantic-settings`, `ORDERS_*` variables, production guards that refuse dev secrets | 08 |
| Database | async SQLAlchemy 2, one session per request, `lazy="raise"`, `selectinload`, UTC-aware datetimes | 15 |
| Auth | OAuth2 password flow, bcrypt in the thread pool, short-lived JWTs with pinned algorithm, audience and issuer | 17 |
| Authorization | customer/admin roles; every query scoped to the owner, so other people's orders are 404 | 18 |
| Contract | camelCase models, separate In/Patch/Out models with `extra="forbid"`, stable operation ids | 04, 20 |
| Retries | `Idempotency-Key` stored in the same transaction as the order; concurrent duplicates create one order | 16 |
| Concurrency | `ETag` / `If-None-Match` (304) and `If-Match` (428/412), plus SQLAlchemy `version_id_col` | 16 |
| Lists | keyset (cursor) pagination with opaque cursors | 16 |
| Live updates | Server-Sent Events status stream that authorizes before streaming and holds no DB session | 14 |
| Webhooks | transactional outbox, signed deliveries (Standard Webhooks style), backoff, dead-lettering | 13 |
| Abuse | sliding-window rate limits per user and per login (failed attempts only) | 18 |
| Errors | RFC 9457 problem documents everywhere, no echoed input, no internals on 500 | 09 |
| Operations | JSON logs with request ids, access log, `/livez` and `/readyz`, graceful shutdown | 21, 23 |
| Delivery | multi-stage non-root Dockerfile, Compose with PostgreSQL | 23 |

## Run it

```bash
pip install -r requirements.txt
uvicorn orders_api.main:create_app --factory --reload
```

Open http://127.0.0.1:8000/docs. On first start it creates `orders.db` (SQLite) and seeds a menu and
three demo accounts (`asha@example.com`, `ravi@example.com`, `admin@example.com`, password
`brew-demo-pass`; development only, refused in production).

## The end-to-end demo

```bash
python demo.py
```

Starts the API and a partner's webhook receiver on free ports with a throwaway database and random
secrets, then exercises everything over real HTTP: logins, idempotent retries and a five-way
double-click race, authorization, ETags, a live SSE stream while an admin moves the order through the
kitchen, pagination, outbox deliveries (with a deliberate 503 and retry), rate limiting, a problem
document, and the JSON log of a graceful shutdown.

## Tests

```bash
pip install -r requirements-dev.txt
python -m pytest
```

51 async tests (AnyIO) against a fresh app, lifespan and SQLite file per test. If your global Python has
broken pytest plugins, run `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest`; the suite loads the
AnyIO plugin itself.

## PostgreSQL

```bash
ORDERS_JWT_SECRET=$(python -c "import secrets; print(secrets.token_urlsafe(48))") docker compose up --build
```

Not run while the course was written (Docker was not available on that machine). The code paths that
differ on PostgreSQL are the engine's pool settings and `FOR UPDATE SKIP LOCKED` in the outbox relay,
which SQLite ignores.

## Production notes and deliberate limits

- **Migrations**: tables are created at start-up for convenience (`ORDERS_CREATE_TABLES`). In production
  use Alembic, run as a job before the rollout, with expand/contract changes.
- **One process, or shared state**: the rate limiter and the SSE broker live in process memory. With
  several replicas, move them to Redis (a sorted set per limiter key, pub/sub or PostgreSQL
  `LISTEN/NOTIFY` for events). The outbox already works across replicas.
- **Idempotency records** should expire (for example after 24 hours) through a periodic cleanup job.
- **Token revocation**: access tokens are stateless and short-lived (15 minutes). Role changes apply when
  the token expires; add a denylist or refresh-token rotation if you need immediate revocation.
- **Receivers must deduplicate** on `webhook-id`: delivery is at least once.
