Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

FHIR Engine

A small, config-driven engine that powers a FHIR R5 server over Django. The guiding rule:

A Django model backs only the JHE-system view of a FHIR resource. Everything else is stored opaquely in a single generic FhirAuxResource table.

Concretely, the Django models hold:

FHIR resourceDjango model (JHE system)Everything else → FhirAuxResource
ObservationObservationOMH only (code system https://w3id.org/openmhealth)any other / code-less Observation
DeviceDataSourceany other Device
GroupStudyany other Group
OrganizationOrganizationany other Organization
PatientPatientany other Patient
PractitionerPractitionerany other Practitioner

Both kinds of resource are declared in core/fhir/fhir_config.json. A mapped resource is projected onto its Django model by a field mapping (read renders the model through the mapping; the model is the system of record). An auxiliary resource has no mapping — its whole FHIR body lives verbatim in FhirAuxResource.fhir_data.

Every incoming FHIR resource is validated against fhir.resources (7.x) on the way in. Reads are not re-validated.

Routing

Each HTTP request maps to a FHIR interaction (search / read / create / update / delete). The config drives which backing store handles it, via two annotations:

Given a resource R with mapped interactions M, aux interactions A, and optional criteria C:

InteractionRouting
searchUNION of the mapped Django rows (if search ∈ M) and the FhirAuxResource rows of that type (if search ∈ A), in one searchset Bundle.
read / update / deleteby id shape — a UUID id targets FhirAuxResource; an integer id targets the mapped Django model. (FhirAuxResource uses a UUID primary key, so the two id spaces never collide.)
createif create ∈ M and (C absent or C matches the payload) → mapped model; else if create ∈ A → aux; else 405.

With the shipped config, Device/Group/Organization/Patient/Practitioner are read,search against their model — so all their writes fall through to FhirAuxResource — and Observation is * with the OMH criteria, so an OMH Observation create writes the Observation model while any other Observation create lands in FhirAuxResource.

So searching Group returns the mapped Study rows plus any Group rows in FhirAuxResource; the same holds for every mapped type.

Every mapped model exposes one uniform entry point that the generic handler calls for both search and read:

Model.fhir_search(jhe_user_id, resource_id=None, organization_id=None,
                  study_id=None, patient_id=None, **params) -> QuerySet[Model]

It returns a lazy queryset of model instances (the engine renders each through the config mapping; formatting is never the model’s job). The contract is identical across all six models:

  1. The user is resolved from jhe_user_id via resolve_fhir_user (a single query, both role profiles select_related-ed). There is no is_patient flag — the method decides the branch itself, so handlers and tests just pass an id. An unknown id is a 404.

  2. A patient user gets a self-scoped result. The organization_id / study_id / patient_id filters are ignored; the method returns the rows that belong to that patient (their own observations, the studies/organizations/devices/practitioners they are attached to, or their own Patient record).

  3. A practitioner gets an organization-membership-scoped result. The base queryset is anchored on the practitioner’s organizations, then narrowed by whichever explicit filters are present. Each targeted filter is authorized up front by authorize_practitioner_scope: an organization_id they do not belong to, a study_id under an organization they are not in, or a patient_id who shares no organization with them raises 403. A paramless practitioner search returns everything across their authorized organizations (the “return-all” rule).

  4. resource_id narrows to a single row by primary key. The view only ever passes it for a read of an integer id (UUID ids are routed to FhirAuxResource first), and it is applied inside the same authorization scope — so reading an id you may not see is a clean 404, not a 403.

  5. **params carries the non-location filters — patient_identifier_system / patient_identifier_value (Patient, Observation) and coding_system / coding_code (Observation). Every model accepts **params and ignores the keys it does not use. An identifier is a search predicate, not a targeted resource: it is not authorized (no 403) — the organization join already scopes the result, so an unmatched/unauthorized identifier just yields an empty set.

  6. Filters chain (AND). Passing several at once narrows progressively.

Query parameters → fhir_search kwargs

The generic handler (MappedResourceHandler._search_kwargs) translates the canonical FHIR search params for every mapped resource:

Query parameterkwarg
?patient=<id>patient_id
?patient.organization=<id>organization_id
?patient._has:Group:member:_id=<id>study_id
?identifier=<system>|<value> / ?patient.identifier=<system>|<value>patient_identifier_system / patient_identifier_value
?code=<system>|<value>coding_system / coding_code
path id .../<resource>/<id>resource_id

camelCase caveat: the client sends the FHIR-standard patient._has:Group:member:_id (capital Group), but djangorestframework_camel_case snake-cases every incoming query-param key before it reaches request.GET, so the server actually reads patient._has:_group:member:_id. The other keys are already lowercase and pass through unchanged. See the note in _search_kwargs.

Per-model behaviour

In every row below, the practitioner paths are additionally bounded by the practitioner’s own organization membership (and authorized, 403 on a targeted mismatch); the patient path ignores the location filters and returns the self-scoped set.

Model (resource)organization_idstudy_idpatient_idextra **paramspatient user sees
DataSource (Device)devices in studies under that orgdevices used in that studydevices used in the studies that patient is indevices in the studies they are enrolled in
Study (Group)studies under that orgthe single studystudies that patient is enrolled inthe studies they are enrolled in
Organizationthe single orgthe org backing that studythe orgs that patient belongs tothe orgs they belong to
Practitionerpractitioners in that orgpractitioners in that study’s orgpractitioners in the orgs that patient belongs topractitioners in the orgs they belong to
Patientpatients in that orgpatients enrolled in that studythe single patientidentifier → the patient with that identifieronly themselves
Observationobservations of patients in that orgobservations of patients enrolled in that study whose code is one of the study’s requested scopesthat patient’s observationsidentifier → that patient’s; code → matching system|codetheir own observations

Notes:

The auxiliary store follows the same normalized contract, with one extra required argument — the resource_type, since the single FhirAuxResource table holds every aux type:

FhirAuxResource.fhir_search(
    jhe_user_id,
    resource_type,
    resource_id=None,
    organization_id=None,
    study_id=None,
    patient_id=None,
    **params
)

Each aux row reaches its owning patient through its FhirSource (FhirAuxResource → FhirSource → Patient), so the filters are expressed against fhir_source__patient: patient_id → that patient’s rows; organization_id / study_id → the rows of all patients in that organization / study; resource_id → the single row by UUID. The patient/practitioner split and the authorize_practitioner_scope 403s are identical to the mapped models. The AuxResourceHandler calls it for both search and read, sharing the same _canonical_search_kwargs query-param translation as the generic mapped handler — except the X-JHE-FHIR-Source-ID header wins when present (it pins the read to that source’s patient and the query params are ignored). (Writes still resolve their target row through FhirAuxResource.for_patient, since a write always names a source and therefore a concrete patient.)

Components

FileResponsibility
core/fhir/fhir_config.jsonDeclares mapped_resources (field mappings + meta.__interaction / __criteria) and aux_resources (resourceType + __interaction).
core/fhir/config.pyLoads the JSON once at import; exposes get_resource_mapping, mapped_interactions / aux_interactions, mapped_criteria, mapped_model_name, and get_config_errors() (validation, see below).
core/fhir/engine.pyThe renderer: build_fhir_resource (model → FHIR dict), render_resource, matches_criteria, expand_interactions.
core/fhir/fhir_validation.pyvalidate_fhir_resource(resource_type, data) — parse an incoming FHIR body against its fhir.resources model (DRF 400 on failure).
core/serializers/observation.py, core/serializers/patient.pyFHIRObservationSerializer / FHIRPatientSerializer call the engine. (Observation Base64-encodes valueAttachment.data afterwards.)
core/serializers/aux_resource.pyFHIRAuxResourceSerializer returns a FhirAuxResource’s stored body verbatim (with resourceType/id forced).
core/fhir/scope.pyresolve_fhir_user (patient-vs-practitioner from the jhe_user_id) and authorize_practitioner_scope (403 on an unauthorized organization/study/patient), shared by every model’s fhir_search.
core/views/fhir.pyFHIRResourceView — the unified endpoint, routing table, the generic mapped handler, and the aux handler.
core/fhir/pagination.pyWraps serialized resources in a FHIR searchset Bundle.

The configuration

mapped_resources and aux_resources are arrays of objects carrying a "resourceType". A mapped entry additionally holds its field mapping — a tree of dicts, lists, and strings, where strings are tiny expressions (literal "'final'", path "DataSource.name", or +-concatenation "'Patient/' + Observation.subject_patient"). The path prefix is the Django model backing the resource (which can differ from the resourceType — a Device is a DataSource, a Group a Study). Output keys are FHIR field names in camelCase. (The rendering rules — fan-out of related managers via as_fhir_element(), materializing a single FK to its pk, and pruning empty leaves/templates — are unchanged from the original engine; see the code comments in core/fhir/engine.py.)

Validation (get_config_errors, lazy, 500 on failure)

FHIRResourceView calls get_config_errors() on each request (cached) and returns a 500 OperationOutcome listing any problems. The five checks (core/fhir/config.py):

  1. Every entry — mapped and aux — has a non-empty __interaction.

  2. Each interaction is one of create/read/update/delete/search or "*".

  3. A mapped resource whose interactions cover everything ("*") must declare __criteria (otherwise it could never fall back to aux).

  4. Every path resolves on the backing model: the model is the path prefix (resolved via apps.get_model("core", name)), and each dotted segment must be a field, a @property, or an FK hop (e.g. Patient.jhe_user.email, Observation.codeable_concepts).

  5. Every field name is valid FHIR: each non-__ key of a mapped resource must be a real element of the matching fhir.resources model (ModelClass.elements_sequence()).

Auxiliary resources, FhirSource & the source header

FhirAuxResource (core/models/fhir_aux_resource.py) stores the whole FHIR body in fhir_data, served with full CRUD and no computation. Key points:

FhirSource

A FhirSource (core/models/fhir_source.py) is an upstream FHIR source a patient registers for themselves (fields: patient, data_source, label, fhir_base_url) before uploading FHIR resources. CRUD lives at api/v1/fhir_sources via FhirSourceViewSet, scoped to the requesting patient (their patient is assigned server-side).

The X-JHE-FHIR-Source-ID header

The X-JHE-FHIR-Source-ID header names the FhirSource, from which a request resolves (patient, fhir_source) (resolve_fhir_source_context):

An unknown source is 400 and a source the user may not use is 403. How the header is treated depends on the interaction:

The unified endpoint

A single view, FHIRResourceView, serves every supported resource at FHIR/<version>/<resource> and .../<resource>/<id> (<version> is the config fhir_version, e.g. FHIR/R5/Patient); the lowercase fhir/r5/ path is a backward-compatible alias. It applies the routing table above, dispatching to the generic mapped handler (which translates the canonical search params into the model’s fhir_search and renders each row through the config mapping; ObservationHandler subclasses it only for the Base64 serializer and OMH create) or the aux handler. The FHIR bundle batch stays at POST on the base (FHIR/R5/), served by FHIRBase, which routes each Observation entry by the same OMH criteria. Domain and DRF exceptions are rendered as a FHIR OperationOutcome with the right status by handle_exception.

Adding a resource

Tests