Build your own facade¶
Who this is for: the developer who already has a FastAPI application and wants it to receive FHIR captures - fluent in Python, DHIS2 knowledge optional.
Before you start: a project whose guide you can build a translation context from (Set up an IG project) and what a valid submission is (The capture contract).
You will be able to:
- build a
ConversionContextand translate aQuestionnaireResponsewith it - post each translated payload to the DHIS2 endpoint its shape names
- answer a refusal with the categories, elements, and reasons the translator gave
- decide which of four facade shapes your integration actually needs
- see the point at which writing more of one costs more than running
d2w fhir serve
Two postures¶
d2w fhir serve is the complete facade. It publishes the guide, states its own
capabilities at /metadata, answers the register off the live instance, stores
every submission as a receipt, and hands that spool to d2w fhir forward, which
drains it into DHIS2 and files each receipt under what DHIS2 said. Nothing in
that chain is optional to it: the durability is the product.
Most integrations do not want a second server. They have an application, it
already has authentication, logging, and a queue for work that failed, and what
they need from this toolchain is the one piece they cannot write themselves -
the translation from a captured QuestionnaireResponse into the DHIS2 import
payload it means. That is translate_response, and everything around it is
about fifty lines of FastAPI.
The two are not exclusive. A deployment often serves the guide from
d2w fhir serve for discovery and reads, and receives its own traffic on its own
route, both built from the same published artifacts.
There is a third posture between them: take the facade as a library, in your own
process, with no port bound at all. Embed the facade
is that one - create_app over an ASGI transport, the spool and the projection
as local storage, and the facade's routers mounted inside your own application
behind your own authentication.
The ladder¶
Between those two postures there is a ladder, and each level buys back exactly one guarantee the level below traded away. Four runnable files walk it:
| Level | What it adds | What it still gives up |
|---|---|---|
| minimal | one route: translate, post, answer with DHIS2's verdict | everything else - nothing is written down, and a client per request |
| basic | one client for the process, settings at startup, /health, a log line per verdict |
durability: an unreachable instance is still a failed request |
| complex | a durable spool, 201 before DHIS2 is asked, a background drain, receipts readable by id |
tracker routing, the coded-answer dial, overwrite naming, any published surface |
| advanced | tracker routing, the strict/lenient dial, overwrite naming, a small /metadata |
the register, the guide, capture screens, requeue, a drain that is its own process |
Read the last column downwards. Each level's remaining gap is the next level's subject, and the last level's gap is the served facade itself - which is the whole argument of When to stop building.
The levels are cumulative in guarantees, not in code: each file is standalone and runs on its own, so a level can be read cold without the three below it.
Level one: minimal¶
examples/fhir/client/minimal_facade.py -
one file, one route, and a __main__ that drives it in-process. This section
walks it in the order the code reads.
Build the context once¶
A ConversionContext carries everything the translator reads besides the
response: the served forms, the option terminology, the published Location
table it resolves organisation units through, and the naming a project's
extensions are written in.
from dhis2w_fhir import build_project_context, load_project, service
project = load_project(project_root)
artifacts = await service.fetch_live_artifacts(client, project)
context = build_project_context(project, artifacts)
It is a BaseModel and it is frozen, so build it at start-up, keep it for the
process, and rebuild it when the instance's metadata moves. fetch_live_artifacts
reads the instance; load_compiled_artifacts reads a SUSHI-compiled guide off
disk instead, and both produce the same thing.
Receive the response¶
@app.post("/QuestionnaireResponse")
async def capture(response: QuestionnaireResponse) -> JSONResponse:
dhis2w_fhir.r4.QuestionnaireResponse is a pydantic model, so FastAPI parses and
validates the body before the function runs. Every R4 model here is closed: an
unknown key, or a value[x] the element does not have, is FastAPI's own 422 and
never reaches the translator.
Translate, and answer a refusal¶
result = translate_response(response, context)
if result.is_refused:
refusals = [refusal.model_dump(mode="json", exclude_none=True) for refusal in result.refusals]
return JSONResponse(status_code=422, content={"refusals": refusals})
A refusal is about the response alone - the instance is never asked about it.
Each ConversionRefusal carries a category a caller can route on, a reason
written for a person, and the link_id or element it stumbled on:
{
"refusals": [
{
"category": "unknown-form",
"reason": "`http://example.org/fhir/Questionnaire/NotAServedForm` is no form this context carries",
"element": "QuestionnaireResponse.questionnaire"
}
]
}
translate_response is a pure function of the response and the context, so this
half of the route is testable without a DHIS2 instance anywhere near it.
Post the payload to the endpoint its shape names¶
ConversionResult.payload is whichever document the translation produced, and
the typed field that carried it says where it goes:
| The result carries | Endpoint | Body |
|---|---|---|
data_value_set |
/api/dataValueSets |
the envelope itself |
tracked_entity |
/api/tracker |
{"trackedEntities": [payload]} |
enrollment |
/api/tracker |
{"enrollments": [payload]} |
event |
/api/tracker |
{"events": [payload]} |
Exactly one is ever set. target_kind alone cannot decide it: a registration
produces a tracked_entity for a person DHIS2 does not hold yet and an
enrollment alone for one it does, under the same kind - and an enrollment that
rides inside a trackedEntities wrapper rewrites the person's owning
organisation unit, which is not this submission's to move.
The tracker endpoint is posted with importStrategy=CREATE and async=false, so
the answer to the request is the import report rather than a job to poll.
async with open_client(profile) as client:
verdict = await client.post_raw(path, body, params=params)
Pass DHIS2's verdict back under DHIS2's status¶
A refused import is not an error. DHIS2 answers one with 409 Conflict
carrying its own report, and a payload it will not read at all with a 400, so
the route catches Dhis2ApiError and answers with the instance's own status
and body:
except Dhis2ApiError as error:
if error.status_code >= SERVER_ERROR_STATUS or not isinstance(error.body, dict):
raise
return JSONResponse(status_code=error.status_code, content=error.body)
Two things are re-raised rather than passed on. A body that is not a report at
all - an authentication failure, an unreachable instance - is about the run and
not about this capture. And a 5xx is the instance failing rather than
answering: DHIS2 and the proxies in front of it wrap a server error in a
WebMessage with a status in it, and reading that as a verdict would file a
refusal DHIS2 never made.
The three verdicts the demo drives¶
The example's __main__ posts three captures, which are the three answers a
facade can give:
| The capture | Answer | Who decided |
|---|---|---|
| a form the context carries | 200 with the import summary |
DHIS2 took it |
| a place the form is not collected at | 409 with conflict E8022 |
DHIS2 refused it |
| a form the context does not carry | 422 with unknown-form |
the translator refused it |
The middle one is worth a second look, because it is the only capture in these
four examples that is well formed and still refused. It answers a served form,
names a published Location, states a period the calendar holds, and types
every answer the way the form types it - so the translator has nothing to object
to. What it reports is a chiefdom, and the seeded instance collects Child Health
at facilities. Only the instance knows that, which is why the verdict has to
come back from DHIS2 rather than from the translator.
That division is worth stating plainly, because it decides where your own failures will come from: the translator guards the payload thoroughly - the form, the place, the period, the answer types, the codes - so what is left for DHIS2 to refuse is what only DHIS2 knows. Assignment, as here. And, on a live instance, the state a demo instance does not carry: a locked period, an approved data set, sharing that was revoked since the form was published.
Validate without writing¶
Both endpoints have a mode that grades a payload and stores nothing -
dryRun=true on /api/dataValueSets, importMode=VALIDATE on /api/tracker.
The example's __main__ posts under it, which is what lets the example run in
make verify-examples without leaving data on the instance. A real facade drops
the parameter.
Level two: basic¶
examples/fhir/client/basic_facade.py -
the same one route, in the shape a deployment runs it in. Four changes, no new
guarantees about the capture itself.
Settings resolved once. FacadeSettings.resolved() reads the profile at
startup - DHIS2_PROFILE, or the configured default - so a process that cannot
name its instance fails at boot rather than at the first capture.
One client for the process. open_client reads /api/system/info to bind
the version tree, so opening one per capture is a second round trip per capture.
The client is opened in a FastAPI lifespan and held in a small runtime model:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with open_client(settings.profile) as client:
runtime.client = client
yield
runtime.client = None
The lifespan is what uvicorn runs at startup and shutdown. Note what the
example's __main__ has to do about that: httpx2.ASGITransport calls the
application and nothing else, so a demo that only wrapped the app in a transport
would find runtime.client still None inside every route. Entering
app.router.lifespan_context(app) by hand is the fix, and it is exactly what
asgi-lifespan's LifespanManager does for a test suite.
/health. One uncached read of /api/system/info, answered as a small
HealthReport: whether DHIS2 is reachable, which instance and profile, and the
version it reported. Uncached on purpose - a cached answer says the instance was
reachable once, which is not the question. An unreachable instance is a 503
carrying the sentence that explains it.
One log line per verdict. Accepted, refused by the translator, refused by
DHIS2 - each is one line through the logging module, so a capture six weeks
old is answerable.
The trade is unchanged from level one and worth naming again: a capture that arrives while DHIS2 is unreachable is a failed request, and its sender is the only place it exists.
Level three: complex¶
examples/fhir/client/complex_facade.py -
where a capture starts surviving. The route's answer stops being DHIS2's verdict
and becomes a receipt id.
Receive, write, answer - in that order¶
result = translate_response(response, runtime.context) # validation, not the send
if result.is_refused:
return JSONResponse(status_code=422, content={"refusals": refusals})
runtime.spool.save(envelope) # durable: fsync, rename, fsync
return JSONResponse(status_code=201, content=accepted.model_dump(mode="json"))
Translating at the door is validation: a response the translator will not read is
refused while there is still a client on the other end of the request to tell.
Everything after that is written down first and posted later, and the 201
carries the id the capture is readable under from then on.
The spool is the library's, not the example's¶
Nothing about the receipt tree is reimplemented in the example. It is the same
tree d2w fhir serve writes and d2w fhir forward drains, through the same
published primitives:
| Side | Module | What the example uses |
|---|---|---|
| write | dhis2w_fhir_serve.spool |
ResponseSpool.save, get, import_report, refusal_record |
| drain | dhis2w_fhir.spool |
read_received_responses, move_to_forwarded, move_to_rejected, record_refusal, drain_lock |
ResponseSpool.save writes through a temporary sibling, fsyncs the file,
renames it, and fsyncs the directory. That ordering is the whole reason a
201 from this facade is a promise rather than a hope, and it is not a thing
worth having a second version of - which is why the example imports it.
Where the tree sits is SpoolLayout.resolve(project_root), the same resolution
of [serve] spool_dir both halves of the served chain read, so a project that
moved its receipts moved them for the writer and the drain at once. The example
uses a scratch directory and says in a comment what a deployment uses instead.
The drain¶
A background asyncio task, started in the lifespan and cancelled before the
client closes under it. Each pass takes the spool's own drain_lock - the same
lock d2w fhir forward takes, because this facade's drain is exactly the kind
of second drain that lock exists to refuse - reads received/, and files each
receipt the moment its verdict is known:
- DHIS2 took it:
move_to_forwarded(spooled, record), with the import counts and the cells the payload landed on written into the sidecar beside it. - DHIS2 refused it:
move_to_rejected(spooled, record), with DHIS2's own reasons in the sidecar. - The translator refused it: nothing moves.
record_refusalleaves the reason beside the queued receipt, and the next pass is the retry. A guide can move between the capture and the drain, so this is not a case that cannot happen. - The instance did not answer: nothing moves and nothing is lost. The pass logs it and the next pass reads the same queue.
Reading a receipt back¶
GET /receipts/{id} answers where a capture stands: its state, and whatever the
drain wrote beside it - DHIS2's import counts once a drain has asked, or the
translator refusal record while it is still queued. A receipt is readable in
every state, forever, because expiring the id a client was handed at capture time
would break that client on a schedule nothing told it about.
The example's __main__ posts one capture, polls its receipt until the drain has
filed it, and prints the journey. It imports for real and deletes the two values
again at the end, so a run leaves the instance as it found it.
Level four: advanced¶
examples/fhir/client/advanced_facade.py -
the durable level plus the four things it lacked.
Tracker routing. post_payload branches on which field the translation
filled and posts to the endpoint that shape names, projecting either answer into
one ForwardImportRecord: /api/dataValueSets answers an ImportSummary
wrapped in a WebMessage, /api/tracker answers a TrackerImportReport bare,
and a reader of either wants the same three things - which rule, which object,
what it said. The demo captures an event of a program without registration.
The coded-answer dial. strict or lenient is application configuration
here, resolved at startup and stamped onto the context so every translation the
process runs reads coded answers the same way:
It is the same dial [serve] strict_codes sets for a served facade and
d2w fhir forward inherits - see
What --strict-codes turns into a refusal.
Overwrite naming. Before a pass posts an aggregate payload it builds
build_forwarded_cell_index(layout) - one read of the sidecars in forwarded/ -
and names every value a forwarded receipt already sent. DHIS2 cannot answer that
question: it replaces such a value in place and counts the write exactly like a
first entry, so no import summary separates the two. Nothing is refused over it;
the drain says so and the operator decides. The demo posts the same two cells
twice to make the naming happen.
A small /metadata. The forms this facade accepts, the two routes it answers
on, and the dial it is running under - and explicitly not a FHIR
CapabilityStatement. A CapabilityStatement is a claim about a FHIR server:
resources served, searches answered, interactions supported. This facade serves
no FHIR resource and answers no search, so publishing one would be a claim it
cannot keep.
When to stop building¶
Each level closes the gap below it. The gap left at the top is not a fifth level -
it is d2w fhir serve plus d2w fhir forward, and every item is something the
served chain already does:
| Still missing at level four | What the served chain provides |
|---|---|
| capability discovery | a real /metadata CapabilityStatement, stating the resources served, the searches answered, and the operations that exist - Consume the FHIR API |
| the guide itself | the published implementation guide served beside the API, so a client reads the forms it is submitting against - Serve the guide |
| the register | Patient search and read off the live instance, which is how a client resolves who a person is |
| capture screens | the capture UI a person fills a form in, served from the same process |
| a refused receipt's second chance | d2w fhir spool lists the queue and d2w fhir requeue puts a rejected receipt back in it once the cause is fixed - Forward captures into DHIS2 |
| a drain that is its own process | d2w fhir forward runs on its own schedule, so a slow or unreachable instance cannot hold up the process that is receiving captures |
| the five form kinds, all of them | every profile of the capture contract, including registration and stage forms |
| completeness registration | a completed aggregate response also registers the data set complete, after DHIS2 takes the values - Data set completeness |
| a drain report | ForwardReport - counts, per-receipt outcomes, rejection reasons rolled up by cause - forward_spool.py |
The rule of thumb. Take your own route while the answer to the request is
the whole of what the sender needs, and the durability lives in your
application. Climb a level when a capture has to outlive the request. And when
you find yourself writing the level above the fourth - a register, a
CapabilityStatement, a requeue command - stop: you are rebuilding
d2w fhir serve smaller, and it is one command.
Next¶
Read the four files, in order:
minimal_facade.py,
basic_facade.py,
complex_facade.py,
advanced_facade.py.
Each runs with uv run python examples/fhir/client/<name>.py. What counts as a
valid submission - the five form kinds, the required extensions, and every rule a
response must meet before any of this applies - is
The capture contract. The Python surface itself is
documented in the dhis2w_fhir API reference.