HAPI is a FHIR storage engine. It validates resources, indexes search parameters, and returns Bundles. What it does not do: authenticate users, enforce per-tenant access control, generate correct URLs behind a reverse proxy, or stream large responses without buffering.
We run FastAPI in front of HAPI on Google Cloud Run. Here are the five things the gateway does that HAPI can't.
1. Auth Injection: One Hook, Every Request
In production, HAPI sits behind Cloud Run's IAM layer. Every request needs a GCP identity token. The pattern: an httpx event hook that fires before every outbound request.
async def _inject_hapi_auth(request: httpx.Request):
token = await get_id_token(audience_url)
request.headers["Authorization"] = f"Bearer {token}"
The bug we hit: auth injection was per-route. The main proxy had it. Portal routes didn't. Everything worked locally (no IAM). Four endpoints returned 403 in production.
Lesson: Auth belongs on the HTTP client, not on individual routes. One hook, one place, every request.
2. The --proxy-headers Trap
This will save you a day if you're running FastAPI behind any load balancer.
Cloud Run terminates TLS. Your app gets HTTP. The load balancer passes X-Forwarded-Proto: https. Uvicorn ignores this by default. So request.base_url returns http://.
The cascade:
- SMART discovery advertises
http://token endpoint - Client POSTs auth code to
http://URL - Cloud Run 302 redirects HTTP → HTTPS
- 302 converts POST to GET (per HTTP spec)
- Token endpoint gets GET → 405 Method Not Allowed
Nine places in our code use request.base_url. All nine broke. The fix:
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000",
"--proxy-headers", "--forwarded-allow-ips", "*"]
If you're running uvicorn behind any reverse proxy and generating URLs from request.base_url, you need these flags. You won't catch this locally.
3. Tag-Based Access Control
HAPI has no per-user access control. Our gateway appends _tag parameters to scope searches by tenant/dataset.
The gotcha: HAPI ignores _tag on instance reads. GET /fhir/Patient/abc-123 returns the resource regardless of tags. Our gateway does post-fetch verification — fetch the resource, check its tags, return 404 if they don't match.
resource_tags = {t["system"] + "|" + t["code"] for t in resource.get("meta", {}).get("tag", [])}
if not resource_tags.intersection(allowed_tags):
return 404
Not ideal — the resource is fetched and discarded. But HAPI doesn't support tag filtering on instance reads.
4. Streaming Large Responses
A FHIR $everything can return megabytes. Don't buffer it.
resp = await client.send(req, stream=True)
return StreamingResponse(
resp.aiter_bytes(),
status_code=resp.status_code,
media_type="application/fhir+json",
)
One gotcha: if HAPI returns 503, detect it before starting the stream. Once you've started a StreamingResponse, you can't change the status code.
5. The Catch-All Route Ordering Trap
Our FHIR proxy has a catch-all: /fhir/{path:path}. This matches everything — including /fhir/Patient/$export and /fhir/DocumentReference/$docref, which have dedicated handlers.
If the catch-all mounts first, those routes are unreachable.
# Order matters
app.include_router(bulk_export_router) # most specific
app.include_router(docref_router)
app.include_router(fhir_router) # catch-all last
Sounds obvious. Costs an afternoon when you add a new router six months later and forget.
Build the Gateway First
If you're deploying a FHIR server to production, build the gateway layer before you build features on top. Auth, URL generation, access control, streaming, and route safety affect every endpoint you'll add later. The FHIR server stores data. The gateway decides who sees it.
mock.health handles the gateway — SMART auth, access control, streaming — so you can focus on your application. Try it free →

Top comments (0)