Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

FHIR connectors

Many systems around a CDR speak FHIR. FerroEHR ships a set of FHIR R4 connectors so it can take FHIR resources in, hand openEHR data back out as FHIR, and emit FHIR resources to downstream systems, all driven by mappings you control. It is not a full FHIR server; it is a focused, mapping-driven bridge between the FHIR and openEHR worlds.

There are two independent switches, an inbound/read-façade one and an outbound-emission one, because the two have very different data-exposure characteristics. All FHIR routes are relative to the API base path (/ferroehr/rest/openehr/v1) and speak application/fhir+json; every response on this surface (success or failure) is a FHIR resource, so an error arrives as an OperationOutcome rather than the openEHR error body.

Note

R4, and what that means if you run R4B. The connector’s routes are /fhir/r4/…, and the resources it exchanges (Bundle, OperationOutcome, AuditEvent) are identical in R4 and R4B, so an R4B client can use this surface unchanged: HL7 states that “implementers that do not use the specific portions where changes have been made can continue to use either R4 or R4B without any functional difference” (what R4B changed). Which resource types you may actually exchange is set by your mappings, not by the release. One neighbouring subsystem is deliberately different: the external terminology servers FerroEHR calls out to are R4B, because there the release belongs to the server you point it at.

Inbound ingestion

POST /fhir/r4/{resource_type} takes a FHIR resource and stores it as a validated openEHR composition. The connector resolves the mapping for the resource type (and its meta.profile, when the resource declares one), resolves or creates the EHR from the resource’s subject, builds a composition from the mapping, stamps it with a FEEDER_AUDIT recording the FHIR origin, and commits it through the normal validated write path.

Outcomes worth designing against:

  • A successful ingest is 201, with ETag and Location headers pointing at the openEHR composition that was created.
  • A mapped composition that fails validation is 422, and nothing is stored.
  • A resource type outside the connector’s starter set (Patient, Observation, Condition, DocumentReference) is 501, refused before the backend is touched.
  • A type inside the starter set with no enabled mapping stored for it is 404. This is the common first-run surprise: the connector is on, but nothing is mapped yet.

Provenance is not optional: the composition the CDR stores carries a FEEDER_AUDIT naming the FHIR origin and the source resource’s own id, so an ingested record is always distinguishable from one authored in openEHR.

Validating without committing ($validate)

POST /fhir/r4/{resource_type}/$validate is the ingest door’s dry twin, following FHIR R4’s own validation operation convention. It runs the whole ingest pipeline (mapping resolution, the FLAT build, the FEEDER_AUDIT stamp, and the same validation the real commit runs) and commits nothing: no composition, no version, and no EHR is created (the target EHR is resolved and reported, never touched).

The response is a FHIR OperationOutcome, and a completed validation is 200 whichever way the verdict falls:

  • Valid: information issues, the verdict naming the resolved template, plus the EHR disposition (would commit into existing EHR <id>, or would create a new EHR for subject '<id>').
  • Invalid: an error issue carrying the openEHR validator’s rejection verbatim (the exact text the real ingest would refuse with as a 422) plus the same disposition issue.

Operation-level failures mirror the ingest door: no enabled mapping is 404, a type outside the starter set is 501, a malformed body is 400, and the disabled connector is 404. This is what makes mapping development safe: iterate on a mapping with $validate against real sample resources, and only switch to the real POST once the outcome reads valid.

curl -s -X POST "$CDR/fhir/r4/Observation/\$validate" \
  -H 'Content-Type: application/json' \
  --data-binary @observation.json | jq '.issue[].diagnostics'

Read façade

GET /fhir/r4/{resource_type}?patient=<subject> returns openEHR data reverse-mapped into a FHIR searchset Bundle. Each entry is produced from a stored composition by running the mapping in reverse.

The patient parameter is mandatory; a missing or blank one is a 400. This is a targeted façade, not a general FHIR search engine: there is no free-text search, no chained parameter, and no _include. An optional _count caps the entries returned per mapping.

The EHDS priority categories, and what round-trips

The six priority categories of personal electronic health data are Annex I of the EHDS regulation, and Annex II 2.1 to 2.3 require an EHR system to provide and receive them in the European electronic health record exchange format. That format is set by implementing acts under Article 36 which have not been adopted, so this table is not a conformance claim against it. What it says is narrower and checkable: which category has a committed template whose example composition round-trips through this façade today.

Annex ICategoryCommitted templateA profile mapping would targetTransform proven
1Patient summariescorpus/templates/ckm/international-patient-summary.optBundle (IPS-shaped) over Patient, Condition, AllergyIntolerance, MedicationStatementYes
2Electronic prescriptionscorpus/templates/ckm/eprescription-fhir.optMedicationRequestYes
3Electronic dispensationsMedicationDispenseNo committed template
4Medical imaging studies and related imaging reportscorpus/templates/ckm/ccta-report.optDiagnosticReport (+ ImagingStudy for the study itself)Yes
5Medical test results, including laboratory and other diagnostic resultscorpus/templates/ckm/generic-lab-test-result.optDiagnosticReport + ObservationYes
6Discharge reportsComposition (discharge summary) + EncounterNo committed template

“Transform proven” means exactly what the test asserts, and no more (app/ferroehr/tests/it/fhir_priority_categories.rs): the category’s committed operational template builds a Web Template, its committed example composition flattens against that template to a non-empty map, and a mapping entry over a leaf taken from that map drives the reverse transform to a FHIR resource carrying the composition’s own value. The leaf is derived from the map rather than written into the test, so a corpus refresh moves it and the case still holds.

Three things it deliberately does not establish, each of which a reader could otherwise assume:

  • No profile mapping ships. The rightmost column says what a mapping for that category would target, not what exists. The test maps its leaf onto Observation.note.text, a free-text element: it proves the machinery carries real clinical content, not that an IPS-shaped Bundle or a MedicationRequest has been authored and reviewed. Mappings are data a deployment registers, as the section above describes.
  • It exercises the transform, not the endpoint. The test calls the reverse transform directly, so routing, authorization and the stored-mapping lookup are covered by other tests rather than by this table.
  • It is not conformance to the exchange format, which does not exist yet.

The connector this table measures is planned to leave: FerroBRIDGE (https://github.com/rubentalstra/FerroBRIDGE) is the FHIRconnect and OMOP bridge, and #3080 retires the in-tree connector once it ships. The EHDS readiness question does NOT leave with it — it is asked of the EHR system — so this table moves to the compliance chapter at that point rather than being deleted with the page it currently sits on.

Two of the example compositions this rests on — the patient summary and the imaging report — were patched by hand rather than regenerated against a running server, which their pack’s provenance records and #1724 tracks. They are real CKM templates either way; the caveat belongs beside a claim that leans on them.

The decision on profile mappings: wait for the implementing acts

No profile mapping ships for any priority category, and none will be authored here. That is a recorded decision (#3206), not an omission, and it rests on two things being unfixed at once.

The target format is unfixed. Annex II 2.1 to 2.3 require the categories in the European electronic health record exchange format, and that format’s content is set by implementing acts under Article 36 which have not been adopted. An IPS-shaped Bundle, a MedicationRequest or a DiagnosticReport authored today would be authored against a guess at what those acts require.

The place is unfixed too, and settled the other way. Mappings belong to FerroBRIDGE, which is the FHIRconnect and OMOP bridge, and #3080 retires the in-tree connector once it ships its first round trip. A mapping written here would be written against ehr.fhir_mapping rows and the FHIRPath-lite dialect this façade reads, both of which leave with the connector.

What reopens the question. The Article 36 implementing acts being adopted reopens it, because the target stops being a guess. FerroBRIDGE shipping its first round trip settles where the work lands. Either event is a reason to revisit #3206; neither has happened.

What stays in FerroEHR regardless is the readiness statement: which categories are carried, by what, and where the gaps are. That is the table above and the EHDS readiness page.

The two categories with no committed template

Electronic dispensations (Annex I 3) and discharge reports (Annex I 6) have no template in the curated CKM pack, so nothing demonstrates them end to end. That is an adjudicated boundary rather than a backlog item. A CDR stores whatever an operational template defines, so this is a corpus gap and not a storage one. The pack is vendored verbatim from the openEHR CKM, every file being CKM’s own export with its provenance recorded, so filling the two rows would mean authoring a template here and putting it in a pack whose value is that nothing in it was authored here. A deployment holding dispensations or discharge summaries uploads its own operational template, and the façade maps it like any other.

Outbound emission

Outbound emission publishes the mapped FHIR resource for every relevant commit, but the target is an AMQP broker (RabbitMQ), not an HTTP FHIR server. A background task drains the same commit outbox used by change events, reverse-maps each committed composition through every enabled mapping bound to its template, and publishes each resulting resource to a topic exchange (default ferroehr.fhir) with a routing key of <resource_type>.<template_id>, both segments sanitised the way the change-event keys are. Delivery is at-least-once.

Only composition versions produce messages: an EHR_STATUS or a FOLDER carries no mappable template. A row that fails to reverse-map deterministically (a defective stored mapping or template) is retried a few times and then parked: logged at error level, naming the row, and skipped, so one bad commit cannot head-of-line-block every later one. Broker and database failures are treated as transient and never park a row.

Warning

Outbound FHIR messages carry PHI: the payload is the mapped clinical FHIR resource, unlike the PHI-free change-event envelopes. That is exactly why they are a separate switch on a separate exchange (ferroehr.fhir, not ferroehr.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.

Note

The change-event publisher has a health indicator; the outbound emitter does not have one of its own today, so treat its broker as something to monitor at the broker rather than through the CDR’s readiness surface. See Operations.

Mappings are data you manage

There are no bundled mapping files. Each mapping is a stored definition managed through an admin API (classed under admin authorization):

MethodPathPurpose
GET/admin/fhir_mappinglist mappings
POST/admin/fhir_mappingcreate a mapping (201)
GET/admin/fhir_mapping/{mapping_id}get a mapping
PUT/admin/fhir_mapping/{mapping_id}update a mapping
DELETE/admin/fhir_mapping/{mapping_id}delete a mapping (204)

A mapping definition binds one FHIR resource type (optionally scoped to a meta.profile URL) to one openEHR template, and lists field bindings, each mapping an openEHR FLAT path to a FHIR path, or to a constant, shaped by a transform.

Resolution is two-step and deterministic. An incoming resource resolves by its type plus the first entry of meta.profile (only meta.profile[0] is consulted): an enabled mapping whose profile_url exactly matches that URL wins; otherwise the type’s enabled mapping with no profile_url (the type default) applies. A resource declaring no profile matches only the type default. When neither exists, the ingest (and $validate) answer 404.

The stored definition is the deployable artifact: the CDR stores it verbatim and interprets it at ingest time, so a mapping deploys, updates, and rolls back without a server release. Its shape (this is the whole contract, with no openEHR specification governs FHIR interop; the wire vocabulary follows HL7 FHIR R4):

{
  "resource_type": "Observation",
  "profile_url": "http://hl7.org/fhir/StructureDefinition/bp",
  "template_id": "blood_pressure.en.v1",
  "subject": {
    "reference_path": "subject.reference",
    "namespace": "fhir",
    "strip_prefix": "Patient/"
  },
  "context": {
    "ctx/language": "en",
    "ctx/territory": "US",
    "ctx/composer_name": "fhir-connector"
  },
  "entries": [
    { "openehr_path": "blood_pressure/blood_pressure:0/systolic",
      "fhir_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.value",
      "transform": { "kind": "quantity",
        "unit_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.unit" },
      "required": true },
    { "openehr_path": "blood_pressure/blood_pressure:0/diastolic",
      "fhir_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.value",
      "transform": { "kind": "quantity",
        "unit_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.unit" },
      "required": true }
  ]
}

The example binds the HL7 FHIR R4 core blood-pressure profile (systolic LOINC 8480-6, diastolic 8462-4, each a component of one Observation) to a blood-pressure template’s two quantity leaves. subject names where the patient identity lives in the resource and how it becomes the EHR subject (Patient/p-42 → subject id p-42 in namespace fhir); context supplies the FLAT ctx/ defaults every built composition carries (an omitted ctx/time defaults to the ingestion instant). The transforms:

TransformWhat it writes
plain textthe bare FLAT leaf
datean ISO 8601 date or date-time leaf
quantitythe magnitude and unit leaves, the unit read from the resource or fixed
codedthe code, the resolved openEHR terminology id, and optionally the display text

A coded transform carries its own FHIR-code-system-to-openEHR-terminology map, with * as the fallback for any unmatched system. An entry can be marked required, which turns an absent source value into an error instead of a skipped field.

A coded transform can also declare translate, asking for cross-terminology code translation at ingest time:

{ "openehr_path": "…/problem",
  "fhir_path": "code.coding[0].code",
  "transform": { "kind": "coded",
    "system_path": "code.coding[0].system",
    "translate": { "target_system": "http://snomed.info/sct",
                   "concept_map": "http://example.org/ConceptMap/my-map" } },
  "code_map": { "http://snomed.info/sct": "SNOMED-CT" } }

The server resolves each such code through a configured FHIR terminology server’s ConceptMap/$translate (routed by the openEHR terminology the code_map binds the target system to; concept_map optionally pins one map). Only a strictly equivalent match is taken; a wider, narrower, or relatedto mapping is treated as no translation, because writing a non-equivalent code would silently change clinical meaning. When no translation exists, a required entry refuses the ingest and an optional one writes nothing, and the untranslated source code is never passed through under the target terminology. A mapping that declares translate on a deployment with no terminology server configured is refused as a server configuration error rather than silently skipped.

The FHIR-path support is a deliberate subset of FHIRPath: object-field navigation, zero-based array indexing, first(), and single-condition where(path = literal) filters, for example code.coding[0].code, code.coding.where(system = 'http://loinc.org').code, and component.where(code.coding[0].code = '8480-6').valueQuantity.value, not the full FHIRPath language (no other functions, unions, or arithmetic). FHIRPath’s where() filters a collection; because a FLAT leaf holds a single value, this subset takes the first matching element. The mapping is symmetric (the same definition drives inbound ingest, the read façade, and outbound emission) so a field you can ingest is a field you can serve back (a translated entry serves back the stored, translated coding).

Each mapping also carries an enabled flag (default on). Only enabled mappings resolve, for ingest, for the façade, and for outbound emission, which makes disabling one a reversible way to take a resource type out of service.

Note

The template a mapping references must already be ingested (see Templates & validation); creating a mapping against an unknown template is a 400. A mapping’s name is immutable once set, because it is its deployable identity, and a duplicate name is a 409.

The one route here that is not the connector

GET /fhir/r4/AuditEvent sits under the same path prefix but is not part of the FHIR connector: it is the audit trail’s own retrieval surface, returning stored audit records as FHIR AuditEvent resources. It is gated by the local audit record repository rather than by the connector switch. The unscoped retrieval is admin-only; a caller holding the configured authz.rbac.subject_audit_role reads it scoped to one subject, with the patient parameter required. See Audit trail (IHE ATNA).

Enabling the connectors

Both switches are off by default. The inbound/read-façade switch lives under [fhir] and the outbound emitter under [fhir.outbound]; the full table with every default is on Integrations. The essentials:

Environment variableDefaultMeaning
FERROEHR__FHIR__API_ENABLEDfalsemount inbound ingest, the read façade, and the mapping API
FERROEHR__FHIR__OUTBOUND__ENABLEDfalserun the outbound emitter (carries PHI)
FERROEHR__FHIR__OUTBOUND__URLa local development brokeroutbound broker URL
FERROEHR__FHIR__OUTBOUND__URL_FILEunsetread the broker URL from a mounted file instead
FERROEHR__FHIR__OUTBOUND__EXCHANGEferroehr.fhiroutbound topic exchange, kept distinct from the event stream
FERROEHR__FHIR__OUTBOUND__TLSfalseupgrade an amqp:// URL to amqps://

Batch size, poll interval, and publish retries are tunable as well.

When the inbound switch is off, /fhir/r4/* and /admin/fhir_mapping answer 404 without touching the backend. When the outbound switch is off, no emitter task runs. With authentication on, an unauthenticated request to a disabled group is answered 401 first: the group gate sits behind authentication.

Note

The connectors are also a cargo feature (fhir), on in the published images and any default build, and enabling it also enables events because the emitter drains the commit outbox. A slim --no-default-features build contains none of their code and refuses to boot when fhir.outbound.enabled, a configured external FHIR terminology provider, or a FHIR AuditEvent audit sink asks for it. fhir.api_enabled is the exception: those routes are simply not compiled in, so the setting has no effect rather than failing loudly. See From source → Build features.

On Kubernetes

Both switches are reachable through the chart’s config passthrough (Any server setting is reachable). The outbound broker URL carries credentials, so it goes through secrets.fhirOutboundUrl, which the chart mounts as a file:

# values.yaml
config:
  fhir:
    api_enabled: true          # the read façade + mapping API
    outbound:
      enabled: true            # the emitter; carries PHI
      exchange: ferroehr.fhir
      tls: true
secrets:
  fhirOutboundUrl: "amqps://user:pass@broker.example:5671/%2f"

Before you enable outbound: a reachable broker, an egress rule that admits it if the chart’s default-deny egress policy is on, and a deliberate decision: it carries PHI off this system. To turn either off, set its switch to false and upgrade: the inbound routes go back to answering 404, and no emitter task runs.