# Recipe — FastAPI service with `mgf.fastapi`

> **Audience:** consumers building FastAPI services that want
> structured observability, typed errors, and per-request id
> propagation without writing the wiring themselves.
> **Optional extra:** `pip install 'mgf-common[fastapi]'`.

## At a glance

```python
from fastapi import FastAPI
from mgf.fastapi import (
    ExceptionTranslationMiddleware,
    RequestIdMiddleware,
    setup_lifespan,
)
from mgf.common.exceptions import ResourceNotFoundError

app = FastAPI(
    lifespan=setup_lifespan(app_name="myapp", app_version="1.0.0"),
)
app.add_middleware(ExceptionTranslationMiddleware)
app.add_middleware(RequestIdMiddleware)

@app.get("/users/{name}")
async def read_user(name: str) -> dict[str, str]:
    if name == "nobody":
        # Bubbles up as 404 with typed JSON body via the middleware.
        raise ResourceNotFoundError(name=name)
    return {"name": name}
```

What you get for free:

- `bootstrap()` runs at app startup; wires structured JSON logging,
  identity, OTel (when configured), crash reporter. LIFO unwind on
  shutdown.
- Every request gets `X-Request-Id` (auto-generated UUID4 if not
  provided; forwarded if it is). Echoed in the response.
- `AppError` subclasses bubbling out of handlers become typed JSON
  responses with the right HTTP status code. The request id is
  included in the error body when `RequestIdMiddleware` is installed.

## The four pieces

### 1. `setup_lifespan` — bootstrap on app startup

```python
from mgf.fastapi import setup_lifespan

app = FastAPI(
    lifespan=setup_lifespan(
        app_name="myapp",        # optional; auto-derived from package metadata
        app_version="1.0.0",      # optional; auto-derived
        settings=my_settings,     # optional; defaults to MgfSettings()
    )
)
```

Inside the lifespan, `bootstrap()` runs and the resulting `AppContext`
is stashed on `app.state.mgf_context`. On app shutdown, the context
LIFO-unwinds — log handlers detach, OTel flushes, crash hooks reset.

### 2. `RequestIdMiddleware`

```python
from mgf.fastapi import RequestIdMiddleware

app.add_middleware(RequestIdMiddleware)
```

Effect:

- Inbound `X-Request-Id` header (any case variant) → reused.
- Otherwise → generated as `str(uuid.uuid4())`.
- Set on `request.state.request_id` (FastAPI standard surface).
- Set on a contextvar — `mgf.fastapi.current_request_id()`
  returns it from any code path inside the request, including async
  task children (PEP 567 propagation).
- Echoed in the response's `X-Request-Id` header.

Pair with structured logging — emit `request_id` in every log line
to correlate end-to-end. ships an automatic logging filter; for
, add it manually:

```python
import logging
from mgf.fastapi import current_request_id

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = current_request_id() or "-"
        return True

logging.getLogger().addFilter(RequestIdFilter())
```

### 3. `ExceptionTranslationMiddleware`

```python
from mgf.fastapi import ExceptionTranslationMiddleware

app.add_middleware(ExceptionTranslationMiddleware)
```

Catches any `AppError` subclass bubbling out of a handler; maps to
JSON response with the right status code:

| Exception | Status |
|---|---|
| `SchemaValidationError` | 400 |
| `ResourceNotFoundError` | 404 |
| `ResourceAlreadyExistsError` | 409 |
| `HostEnvironmentError` | 503 |
| `ConfigError` / `OperationError` / `VaultError` / catch-all `AppError` | 500 |

Default response shape:

```json
{
  "error": {
    "type": "ResourceNotFoundError",
    "message": "user not found: alice",
    "request_id": "abc-123"
  }
}
```

The `request_id` field is present only if `RequestIdMiddleware` is
installed (it reads from `request.state.request_id`).

#### Custom mapping

```python
from mgf.fastapi import (
    DEFAULT_STATUS_MAP,
    ExceptionTranslationMiddleware,
)
from mgf.common.exceptions import AppError

class TenantIsolationError(AppError):
    pass

app.add_middleware(
    ExceptionTranslationMiddleware,
    status_map={**DEFAULT_STATUS_MAP, TenantIsolationError: 403},
)
```

#### Custom response shape (RFC 7807, JSON:API, etc.)

```python
from starlette.responses import JSONResponse

def problem_json(exc, status, request_id):
    return JSONResponse(
        {
            "type": f"https://errors.example.com/{type(exc).__name__}",
            "title": type(exc).__name__,
            "detail": str(exc),
            "instance": request_id,
        },
        status_code=status,
        headers={"Content-Type": "application/problem+json"},
    )

app.add_middleware(
    ExceptionTranslationMiddleware,
    response_factory=problem_json,
)
```

### 4. `Depends()` helpers

```python
from typing import Any
from fastapi import Depends, FastAPI
from mgf.fastapi import (
    get_app_context,
    get_request_id,
    get_settings,
)

@app.get("/whoami")
async def whoami(
    ctx: Any = Depends(get_app_context),
    settings: Any = Depends(get_settings),
    rid: str = Depends(get_request_id),
) -> dict[str, str]:
    return {
        "app": ctx.app_name,
        "version": ctx.app_version,
        "log_level": settings.logging.level,
        "request_id": rid,
    }
```

Dependencies don't construct anything — they read state set by
`setup_lifespan` and `RequestIdMiddleware`. Missing state raises
`AppConfigError`, which the translation middleware then maps to 500.

## Combined with `mgf.common.http` for upstream calls

```python
from mgf.fastapi import setup_lifespan
from mgf.common.http import AsyncHttpClient, RetryPolicy

app = FastAPI(
    lifespan=setup_lifespan(app_name="myapp", app_version="1.0.0"),
)

@app.on_event("startup")
async def _startup() -> None:
    app.state.upstream = AsyncHttpClient(
        base_url="https://api.upstream.com",
        retry=RetryPolicy(max_attempts=3),
    )

@app.on_event("shutdown")
async def _shutdown() -> None:
    await app.state.upstream.aclose()

@app.get("/proxy/users")
async def proxy_users() -> list[dict]:
    response = await app.state.upstream.get("/users")
    response.raise_for_status()
    return response.json()
```

The connection pool persists across requests; redaction +
identity headers + OTel propagation are all baked in.

## Testing with `TestClient`

```python
from fastapi.testclient import TestClient

def test_health_endpoint(my_app):
    with TestClient(my_app) as client:
        response = client.get("/healthz")
    assert response.status_code == 200
```

The `with TestClient(...) as client:` form runs the lifespan
(startup + shutdown). Without `with`, lifespan doesn't fire and
`app.state.mgf_context` stays unset.

## Anti-patterns

- **Don't create a FastAPI app at module import time without lifespan.**
  Without `setup_lifespan`, dependencies that read `app.state.mgf_context`
  fail with `AppConfigError`. Always wire the lifespan up front.
- **Don't add `RequestIdMiddleware` AFTER the translation middleware**
  if you want the request id in error responses. Order matters:
  `app.add_middleware()` adds in REVERSE order of execution — so:
  ```python
  app.add_middleware(ExceptionTranslationMiddleware)  # runs second (outer)
  app.add_middleware(RequestIdMiddleware)              # runs first  (inner)
  ```
  This way the request id is set before the translation middleware
  reads it.
- **Don't roll your own exception → JSON mapping per endpoint.**
  `ExceptionTranslationMiddleware` covers the common cases; extend
  the `status_map` for your domain types.
- **Don't bypass the lifespan in tests.** Pytester / TestClient
  handle this automatically when used as a context manager. If you
  use `TestClient(app)` without `with`, lifespan doesn't fire and
  things break in confusing ways.

## See also

- `docs/recipes/cli.md` — CLI app shape (for non-web consumers).
- `docs/recipes/http.md` — `mgf.common.http` for upstream calls.
- `docs/recipes/testing.md` — pytest fixtures.
- `docs/reference/lifecycle.md` — `bootstrap()` deep dive.
