An OpenHIM mediator that sits between OpenMRS and AdvaPACS and moves radiology orders and results between them as FHIR resources.
- An order is created in OpenMRS (
ServiceRequest). - That order reaches this mediator one of two ways, controlled by
ORDER_INGESTION_MODE(see.env.example):push: something on the OpenMRS side POSTs it (or its id) toPOST /fhir/ServiceRequeston this mediator (src/routes/serviceRequest.js), via OpenHIM's "OpenMRS to Mediator Order Push" inbound channel.poll(default):src/lib/orderPoller.jsperiodically searches OpenMRS's FHIRServiceRequestendpoint for anything new since the last poll, then POSTs each one to that same OpenHIM inbound channel.
- Either way,
routes/serviceRequest.jshands off tosrc/lib/orderRelay.js, which resolves the OpenMRSPatientand, before touching theServiceRequestat all, pushes/updates thatPatientin AdvaPACS first (advapacsClient.js'supsertPatient— searches by identifier, thenPUTs if AdvaPACS already has that patient orPOSTs to create) — if that fails, theServiceRequestis never sent (fail-fast: AdvaPACS needs the patient record to exist before it can match an order to it). Only then doesorderRelay.jsremap theServiceRequest.subjectto a literal reference at AdvaPACS's own Patient id (Patient/{advapacs-id}, taken fromupsertPatient's response) instead of the OpenMRS UUID — a logical identifier-based reference was tried first but made AdvaPACS's real ServiceRequest endpoint 500 with no diagnostic detail. Several other OpenMRS-specific fields are also stripped or reshaped before the outboundServiceRequestis sent (encounter/requester/id/meta/textdropped;code,occurrenceDateTime, andorderDetailreshaped to what AdvaPACS's FHIR R5 API actually expects) — see the inlineTODO/HACK/EXPERIMENTcomments inorderRelay.jsfor the current state and ticket numbers behind each.advapacsClient.jsthen pushes theServiceRequestitself. Both pushes go through a second, outbound OpenHIM channel ("Mediator to AdvaPACS Order Push",^/advapacs/.*$) rather than calling AdvaPACS directly, so each leg is logged and auto-retried by OpenHIM (see Known limitations). - AdvaPACS would perform/read the study, then fire its own FHIR
Subscription(rest-hook) atPOST /webhooks/advapacson this mediator, carrying anImagingStudyorDiagnosticReport— this leg is currently disabled and untested end-to-end, see Known limitations. src/routes/subscriptionWebhook.jswould write that resource into OpenMRS and flip the originatingServiceRequesttocompleted— it's a placeholder today, not yet functional (see Known limitations).
orderPoller.js (every ORDER_POLL_INTERVAL_MS)
--HTTP POST--> OpenHIM inbound channel ^/fhir/ServiceRequest$
--routes to--> mediator's POST /fhir/ServiceRequest (routes/serviceRequest.js)
--calls--> orderRelay.js (resolve patient, reshape ServiceRequest for AdvaPACS)
--calls--> advapacsClient.js upsertPatient() [1: Patient, first]
--HTTP GET/POST/PUT--> OpenHIM outbound channel ^/advapacs/.*$ (auto-retry enabled)
--calls--> advapacsClient.js createServiceRequest() [2: ServiceRequest, only if #1 succeeded]
--HTTP POST--> OpenHIM outbound channel ^/advapacs/.*$ (auto-retry enabled)
--pathTransform strips /advapacs, routes to--> real AdvaPACS host
This scaffold wires up the transport, auth, and registration plumbing
end-to-end and will run. Two things are intentionally left as TODOs because
they need your actual data model, not boilerplate:
- Identifier reconciliation (
serviceRequest.js,subscriptionWebhook.js): right now the AdvaPACS-side id returned on order push is only logged. You'll want a small lookup store (a table, or OpenHIM's own transaction/orchestration log) mapping OpenMRSServiceRequest.id↔ AdvaPACSServiceRequest.id, so the webhook handler can find the right OpenMRS order to update instead of relying onDiagnosticReport.basedOnalone. - Location identifier mapping: the patient side of this is resolved
(
orderRelay.jsreferences the patient via AdvaPACS's own Patient id — see Flow), andencounter/requester(Practitioner) references are dropped from the outboundServiceRequestentirely, since AdvaPACS can't resolve OpenMRS UUIDs for either.Locationreferences aren't handled at all yet — confirm what AdvaPACS expects there if/when that becomes relevant. - Several other fields are temporarily hardcoded or reshaped just to get
a
ServiceRequestpast AdvaPACS's validation — accession-number identifier duplication (UHM-9437/9439/9440), an HL7 "PI" coding stamped onto the patient's EMR-ID identifier (UHM-9443), and the imaging modality hardcoded to X-ray/CRsince OpenMRS doesn't expose it today (UHM-9445). See theTODO/HACK/EXPERIMENTcomments inorderRelay.jsfor the reasoning and ticket numbers behind each.
- OpenHIM's auto-retry only covers connection failures/timeouts to
AdvaPACS, not AdvaPACS returning an HTTP error. The outbound channel has
autoRetryEnabled/autoRetryPeriodMinutes/autoRetryMaxAttemptsset (seemediatorConfig.json,scripts/setupOpenhim.js), but OpenHIM only auto-retries a transaction when the request to the destination itself fails (network error, timeout) or the destination responds with OpenHIM's own mediator-error envelope — a plain 4xx/5xx from AdvaPACS doesn't qualify. A real AdvaPACS error today just surfaces as a failed transaction with no further retry or alerting (see the comment aboveadvapacsClient.js'screateServiceRequest). If that needs to be retried too, it'd need either a translation layer that emits OpenHIM's error envelope, or a separate retry/alerting mechanism — not built here. - The AdvaPACS result-delivery path (webhook) is disabled and untested.
src/routes/subscriptionWebhook.jsis a placeholder — written but never exercised against a real AdvaPACS webhook delivery, since all effort so far has gone into the outbound order-push path. It isn't mounted insrc/index.js,advapacsClient.js'sensureSubscriptionisn't called on startup, and its channel/endpoint entries have been removed frommediatorConfig.json. Each disabled spot is marked with a matching comment — re-enable all three once this path is ready to test. - The outbound AdvaPACS channel is
authType: "public"— deliberately, not an oversight. The inbound channel (OpenMRS/poller → mediator) has real OpenHIM Client auth (authType: "private", anopenmrsClient created byscripts/setupOpenhim.js,orderPoller.jsauthenticating via Basic auth — plus an independent app-levelX-Mediator-Secretcheck insrc/lib/sharedSecretAuth.jsas a backstop against direct access). The outbound channel can't get the same treatment: confirmed directly inopenhim-core-js's source, every non-mTLS OpenHIM client-auth mechanism (Basic, Custom Token, JWT) rides on the sameAuthorizationheader that this channel'sforwardAuthHeader: truealready reserves for passing AdvaPACS's ownAuthorization: ID=...,Secret=...credentials through unchanged — adding OpenHIM auth here would either break that pass-through or never authenticate at all. Mutual TLS is the only OpenHIM-native way around that conflict, but stands up real cert issuance/rotation for a channel whose only caller is the mediator container itself on a private Docker network — disproportionate here. The compensating control is the network instead. The host runs behind a firewall and VPN, and distro-tools'openhimfragment publishes only the ports the integration and its management use (see the comments in itsopenhim.yaml). The router's HTTP port is published for AdvaPACS's result webhook, so it can carry this public channel too: the reverse proxy in front (Caddy on the app cluster) must forward only the paths external systems call (the webhook), never/advapacs/*or the whole router. Otherwise anyone who can reach the proxy can relay requests to AdvaPACS through this channel. Revisit this if the host's trust model changes, e.g. it stops being VPN-only, or an app on it runs withnetwork_mode: hostor joins this instance's Docker network.
OpenMRS doesn't push events anywhere on its own. Pick one, set via
ORDER_INGESTION_MODE:
-
push(needs an OpenMRS-side change): build an event listener module using OpenMRS's event/AOP hooks to POST newly createdServiceRequests to OpenHIM's inbound channel. Nothing on the mediator side needs to change to support this —POST /fhir/ServiceRequestis already mounted and its auth is already mode-agnostic (it's the same endpointorderPoller.jsposts to forpollmode). The exact contract, so a module can be built against this without reading the mediator's source:- Request:
POST /fhir/ServiceRequeston OpenHIM's router (see port/ scheme note below),Content-Type: application/fhir+json. Body is either a fullServiceRequestFHIR resource, or the minimal{ "serviceRequestId": "<uuid>" }form (src/lib/orderRelay.jsfetches the full resource from OpenMRS itself in that case). - Required headers (that channel is
authType: "private", and the mediator's own route checks a second, independent secret):Authorization: Basic base64(OPENHIM_INBOUND_CLIENT_ID:OPENHIM_INBOUND_CLIENT_PASSWORD)— OpenHIM Client credentials, from.env.X-Mediator-Secret: <MEDIATOR_INBOUND_SECRET>— app-level backstop independent of OpenHIM, also from.env.- Both are the same values
orderPoller.jsalready uses for thepollpath — seesrc/lib/orderPoller.js'spollOnce()for a working reference implementation of this exact contract.
- Responses:
200 { status: 'ok', advapacsServiceRequestId }on success;401 { status: 'error', message: 'unauthorized' }if either header is missing/wrong;502 { status: 'error', message }if the relay to AdvaPACS itself fails (e.g. patient resolution, AdvaPACS validation). - Port/scheme: if OpenMRS and this mediator are on the same Docker
network (or otherwise mutually trusted), plain HTTP on port
5001(default — seeOPENHIM_ROUTER_HTTP_HOST_PORTin whatever's running theopenhimservice, e.g. a distro-tools instance'senvfile, if overridden) is fine. If OpenMRS is on a different, less-trusted host, use HTTPS on port5000(default —OPENHIM_ROUTER_HTTPS_HOST_PORT) instead —5001is plain HTTP and would send the credentials above in cleartext across that network. See "Running the full stack locally" below for what changes on this side to support that.
- Request:
-
poll(needs no OpenMRS-side change):src/lib/orderPoller.jsalready implements this — it callsGET {OPENMRS_BASE_URL}/ws/fhir2/R4/ServiceRequest?_lastUpdated=gt...on an interval (ORDER_POLL_INTERVAL_MS) and submits anything new to OpenHIM. Simpler to stand up and works today, at the cost of up-to-ORDER_POLL_INTERVAL_MSlatency and missing anything created while the mediator was down (the poll cursor resets to "now" on restart, it isn't persisted). Note: some OpenMRS FHIR2 module versions don't support astatussearch parameter onServiceRequestat all (confirmed via that endpoint'smetadata) — this poller intentionally doesn't filter bystatusfor that reason.
./build-image.shRuns npm test first (aborting with no build on failure), then builds this
repo's Dockerfile into your local Docker image cache, tagged
openhim-advapacs-mediator:local by default (pass a different tag as $1).
Nothing is pushed anywhere. This just produces the image — it doesn't run or
register it against OpenHIM; see "Running the full stack locally" below for
that.
This repo only builds and publishes the mediator's own image
(partnersinhealth/openhim-advapacs-mediator — see Dockerfile and
.github/workflows/ci.yml); it doesn't bundle OpenHIM itself. To run the
whole stack (OpenHIM + this mediator, optionally alongside OpenMRS too), use
openmrs-contrib-distro-tools,
which has canonical service fragments for both (docker/services/openhim.yaml
and docker/services/openhim-advapacs-mediator.yaml). See distro-tools' own
README for the full env file reference — every
OPENHIM_*/ADVAPACS_MEDIATOR_*/OPENMRS_*/ADVAPACS_* var either fragment
reads, including the OPENHIM_*_HOST_PORT overrides for the published
ports below — plus the lifecycle commands (start/stop/status/logs/
update/add-service/remove-service/destroy) that apply to this stack
the same way they do to any other distro-tools-managed service.
There are two ways to get an OpenMRS instance into the picture:
Add distro-tools' openmrs-db/openmrs fragments to SERVICES alongside
openhim/openhim-advapacs-mediator, and it stands up all four as one
Compose project on a shared network — the mediator's default
OPENMRS_BASE_URL (http://openmrs:8080/openmrs) already points at that
network's openmrs service, so you don't need to set it yourself:
export OPENMRS_IMAGE_NAME=<your OpenMRS distro image, e.g. partnersinhealth/lesotho-emr>
export OPENMRS_PIH_CONFIG=<PIH config profile for this instance, e.g. lesotho,lesotho-kol-ci>
export SEED_IMAGE_NAME=<optional -- a nightly seed image, to skip a slow first boot>
export OPENHIM_PASSWORD=<pick-a-password>
export OPENMRS_USERNAME=<username the mediator uses against OpenMRS's FHIR API>
export OPENMRS_PASSWORD=<password the mediator uses against OpenMRS's FHIR API>
export ADVAPACS_MEDIATOR_INBOUND_SECRET=<pick-a-secret>
export ADVAPACS_MEDIATOR_OPENHIM_INBOUND_CLIENT_PASSWORD=<pick-a-password>
export ADVAPACS_CLIENT_ID=<...>
export ADVAPACS_CLIENT_SECRET=<...>
export ADVAPACS_PATIENT_IDENTIFIER_SYSTEM="http://www.pih.org/identifiers/lesotho/emr-id"
export SERVICES=openmrs-db,openmrs,openhim,openhim-advapacs-mediator
openmrs-docker create <name>
openmrs-docker <name> initialize # optional, only if SEED_IMAGE_NAME is set -- skips the slow first boot
openmrs-docker <name> start
openmrs-docker <name> wait # blocks until OpenMRS itself finishes startingOnce wait reports ready, OpenMRS itself is at http://localhost:8080/openmrs
(or whatever OPENMRS_HTTP_PORT you set).
Alternatively, run OpenMRS on the host via the OpenMRS SDK (openmrs-sdk,
also part of distro-tools) instead of bundling it into the same Compose
project, and point the mediator at it. Useful when you're actively developing
against the distro itself and want the SDK's faster rebuild/redeploy cycle
rather than rebuilding a Docker image on every change:
# 1. Stand up OpenMRS itself via the SDK, from your distro checkout:
cd <path to your distro repo checkout>
PIH_CONFIG=<PIH config profile, e.g. lesotho,lesotho-kol-ci> openmrs-sdk create <server-id>
openmrs-sdk run <server-id> # leave this running in its own terminal --
# Tomcat on localhost:8080 by default
# 2. Separately, bring up just OpenHIM + this mediator via distro-tools,
# pointed at that host-based OpenMRS instance:
export OPENHIM_PASSWORD=<pick-a-password>
export OPENMRS_USERNAME=<username the mediator uses against OpenMRS's FHIR API>
export OPENMRS_PASSWORD=<password the mediator uses against OpenMRS's FHIR API>
export ADVAPACS_MEDIATOR_INBOUND_SECRET=<pick-a-secret>
export ADVAPACS_MEDIATOR_OPENHIM_INBOUND_CLIENT_PASSWORD=<pick-a-password>
export ADVAPACS_CLIENT_ID=<...>
export ADVAPACS_CLIENT_SECRET=<...>
export ADVAPACS_PATIENT_IDENTIFIER_SYSTEM="http://www.pih.org/identifiers/lesotho/emr-id"
export OPENMRS_BASE_URL=http://host.docker.internal:8080/openmrs
export SERVICES=openhim,openhim-advapacs-mediator
openmrs-docker create <name>
openmrs-docker <name> starthttp://host.docker.internal:8080/openmrs (rather than localhost) is
required because the mediator resolves OPENMRS_BASE_URL from inside its own
container — the fragment's extra_hosts entry makes host.docker.internal
resolve back to this machine. If the SDK server's SERVER_PORT isn't the
default 8080, adjust the port to match.
Build this repo's Dockerfile locally, tagged to match what your running instance's fragment expects -- partnersinhealth/openhim-advapacs-mediator:latest
./build-image.sh partnersinhealth/openhim-advapacs-mediator:latest
openmrs-docker <name> start
openmrs-docker <name> logs openhim-advapacs-mediator # confirm clean restartOnce it's up (either way):
- Console UI:
http://localhost:9000(or whateverOPENHIM_CONSOLE_HOST_PORTyou set), log in withOPENHIM_USERNAME/OPENHIM_PASSWORD. - Admin API:
http://localhost:8081(OPENHIM_ADMIN_API_HOST_PORT), plain HTTP. The console runs in your browser and calls this API directly, so both are published; on a server, reach them through its reverse proxy (which adds TLS) or an SSH tunnel (ssh -L 8081:localhost:8081 -L 9000:localhost:9000 <user>@<server>). - OpenHIM's transaction log (the actual FHIR request/response history for
every push through the two channels) lives in the console UI above, or the
admin API directly:
curl -u "$OPENHIM_USERNAME:$OPENHIM_PASSWORD" \ 'http://localhost:8081/transactions?filterLimit=10&filterPage=0' # list curl -u "$OPENHIM_USERNAME:$OPENHIM_PASSWORD" \ 'http://localhost:8081/transactions/<id>' # one transaction's full bodies
- Channel/client provisioning: on startup the mediator registers itself
and
mediatorConfig.jsonwith OpenHIM core, activates its heartbeat, then automatically runsscripts/setupOpenhim.jsto create/update the two order-push channels and theopenmrsOpenHIM Client (idempotent, so it's safe on every boot — a failure here only logs a warning rather than stopping the mediator, and no separate one-shot provisioning step is needed). To force a re-provision without restarting the container (e.g. after only changingmediatorConfig.jsonorADVAPACS_BASE_URL), runnode scripts/setupOpenhim.jsdirectly, with.env'sOPENHIM_*vars pointed at the running instance's admin API. - First run on a fresh instance: OpenHIM core auto-seeds a
root@openhim.orguser with its built-in default passwordopenhim-password, regardless of whateverOPENHIM_PASSWORDyou set.
npm install
npm testUnit tests only — every HTTP call (to OpenMRS, AdvaPACS/OpenHIM's outbound
channel) is mocked with Jest, so nothing needs to be running: no Docker, no
OpenHIM, no OpenMRS. Covers orderRelay.js, advapacsClient.js,
openmrsClient.js, orderPoller.js, and routes/serviceRequest.js. Does
not cover src/index.js's route-mounting/ingestion-mode logic (no
testable seam without a refactor) or subscriptionWebhook.js — see
docs/superpowers/specs/2026-08-06-test-suite-design.md for why.
mediatorConfig.json OpenHIM mediator registration (endpoints, channels, config defs)
scripts/setupOpenhim.js Idempotently creates/updates the two order-push channels + the "openmrs" OpenHIM Client via OpenHIM's API; run automatically by src/index.js on every boot, or standalone via `node scripts/setupOpenhim.js`
build-image.sh Runs npm test, then builds this repo's Dockerfile into the local Docker image cache (no registry) -- see "Building the mediator image locally"
Dockerfile Mediator's own container image
src/index.js Registration + server bootstrap + OpenHIM provisioning; always mounts the push endpoint, additionally starts the poller in 'poll' mode
src/lib/openmrsClient.js OpenMRS FHIR2 client (read/search ServiceRequest/Patient, write results)
src/lib/advapacsClient.js Calls OpenHIM's outbound channel (ADVAPACS_CHANNEL_URL), not AdvaPACS directly; upsertPatient + createServiceRequest (+ ensureSubscription, currently unused -- see Known limitations)
src/lib/orderRelay.js Shared order-relay logic: upserts Patient first, then remaps ServiceRequest.subject to AdvaPACS's own Patient id and reshapes the rest of the ServiceRequest for AdvaPACS before pushing it
src/lib/orderPoller.js Poll-based ingestion (ORDER_INGESTION_MODE=poll) -- submits via OpenHIM's inbound channel
src/lib/sharedSecretAuth.js Shared-secret Express middleware factory (crypto.timingSafeEqual compare) -- backs the inbound X-Mediator-Secret check
src/routes/serviceRequest.js Inbound channel's target; always mounted regardless of ingestion mode; requires X-Mediator-Secret
src/routes/subscriptionWebhook.js PLACEHOLDER result webhook handler -- not yet functional/tested, currently disabled (see Known limitations)