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
searchexactly one store, chosen by the _source param — never a union. _source absent or the JHE-native URI → the mapped Django rows; a .../fhir-source/<id> URI → that source’s FhirAuxResource rows; _source:below=.../fhir-source/ → every imported aux row.
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.

Search never spans both stores. A single query resolves to one table so that filtering and sorting push down to the database on that one table rather than merging two result sets in memory. The client chooses which store with _source: a bare GET /Group returns the mapped Study rows (JHE-native is the default); the imported Group rows in FhirAuxResource are reached with ?_source=https://jupyterhealth.org/fhir/fhir-source/<id> (one source) or ?_source:below=https://jupyterhealth.org/fhir/fhir-source/ (all imported). The same holds for every mapped type. (read/update/delete are single-table via id shape.)

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,
    fhir_source_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; fhir_source_id → the single upstream source (the _source=.../fhir-source/<id> read route). Like an identifier, fhir_source_id is an unauthorized predicate — the organization/patient join already scopes the result, so an inaccessible source simply yields nothing. 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. The X-JHE-FHIR-Source-ID header is write-only — it is ignored on reads (which resolve their store and single-source filter from _source; see below). (Writes still resolve their target row through FhirAuxResource.for_patient, since a write always names a source and therefore a concrete patient.)

Search parameters, _sort & _summary

On top of the store selection above, the endpoint applies the US Core “supported searches” for each resource, plus _sort and _summary. Because a search has already resolved to one store, every filter, sort, and count runs against that single table — mapped rows via the Django ORM, auxiliary rows via a small Postgres JSONB query builder. Nothing merges in memory.

Three tiers of parameters, applied in this order to the authorized queryset the store returned:

  1. Resource-agnostic_id and _lastUpdated (apply_common_search_filters in core/views/fhir.py). Both stores expose an id and a last_updated column, so these are plain ORM filters. _id is an exact match whose value shape decides the store anyway (a UUID never matches a mapped row, an integer never matches an aux row); _lastUpdated takes the ge/le/gt/lt comparators or a bare date (that whole day), and repeats AND together into a range.

  2. Location filterspatient / patient.organization / patient._has:Group:member:_id, already translated to fhir_search kwargs (see the tables above). These are the authorization-scoping filters and are not re-expressed as body predicates.

  3. Resource-specific US Core params + _sort — declared per resource as a __search block in the config and applied by core/fhir/search.py. This is the rest of this section.

The __search config block

Each resource entry (mapped or aux) may carry a __search object mapping a US Core search-param name to a { "type": …, "path": … } spec. The path means different things per store — a mapped path is a Django field/lookup on the backing model; an aux path is a dotted FHIRPath into the stored fhir_data body (camelCase) — because a resource is defined once per store, so each entry carries the form its store needs. Example (Condition, aux):

"__search": {
  "clinical-status": { "type": "token",     "path": "clinicalStatus.coding" },
  "category":        { "type": "token",     "path": "category.coding" },
  "code":            { "type": "token",     "path": "code.coding" },
  "encounter":       { "type": "reference", "path": "encounter.reference" },
  "onset-date":      { "type": "date",      "path": ["onsetDateTime", "onsetPeriod.start"] },
  "recorded-date":   { "type": "date",      "path": ["recordedDate"] }
},
"__sortDate": ["recordedDate"]

A param that is not declared for a resource is simply ignored (FHIR permits a server to ignore unsupported search parameters), never an error. Which params each resource declares is taken straight from the US Core CapabilityStatement’s supported-searches set.

Search-param types (validated by get_config_errors):

typeMatchesStore semantics
tokena system|code against a Coding/CodeableConcept.coding (path → the coding array/element)aux: @.code(& @.system) equality; mapped: exact match on the code part
identifierlike token but against an Identifier (@.value instead of @.code)aux only
codea plain FHIR code scalar (status, intent, …); the token’s system is ignoredaux: @ == code
stringcase-insensitive starts-with over one or more pathsaux: like_regex; mapped: __istartswith
referencea full Type/id or a bare id (any …/id)aux only
datege/le/gt/lt comparator or a bare date (prefix); polymorphic [x] paths are COALESCEdboth
constthe mapped resource renders this element as a fixed literal (e.g. Observation status = final, Device type = data-source)mapped only: the whole result matches iff the requested code equals the constant, else empty

Within one param, comma-separated values OR; a repeated param ANDs (standard FHIR). For date, repeats express a range (recorded-date=ge2021-01-01&recorded-date=le2021-12-31).

camelCase caveat (again): the query-param parser may rewrite a key’s separators, so clinical-status, clinical_status, and clinicalStatus are all matched to the same declared param by a separator-insensitive normalization (core/fhir/search.py::_norm).

Mapped store — Django ORM

For a mapped resource the specs become ORM filters on the model’s own columns (birthdatebirth_date, familyname_family, Observation dateCOALESCE(effective_date_time, effective_period_start)). string uses __istartswith; date compares at day or instant precision depending on the column type; const short-circuits the whole queryset to empty when the requested token does not equal the rendered literal. code/identifier for the mapped resources that already resolve them (Observation code, Patient identifier) stay in fhir_search and are not re-declared in __search, so they are never double-applied.

Auxiliary store — the JSONB query builder

For an aux resource the specs compile to raw Postgres JSONB predicates that are attached to the authorized queryset with RawSQL — so the ORM keeps enforcing the patient/practitioner + organization authorization (as a real queryset, no auth logic duplicated in hand-written SQL) while the body matching runs as raw SQL. Two mechanisms:

Each predicate is annotated as a boolean and filtered on True, so multiple params AND naturally and the annotation is in the SELECT list (required under the queryset’s DISTINCT).

Injection safety. No user value is ever interpolated into SQL or into a jsonpath. Every value reaches Postgres as a bound parameter: jsonpath $vars for the path-exists predicates, positional %s for the #>> date comparisons and #>> path arrays. The one place a value becomes part of a jsonpath — the like_regex pattern for string/reference, which Postgres requires to be a literal, not a $var — is regex-escaped and then escaped as a jsonpath string literal (_jsonpath_literal), and the whole jsonpath is still a bound %s parameter, so a crafted value can neither break out of the pattern nor reach SQL.

_sort

_sort takes a comma-separated list of keys, each optionally --prefixed for descending. Two keys are supported (unknown keys are ignored, per FHIR):

The default order (no _sort) remains -last_updated.

_summary

_summary=count returns just the searchset total — a searchset Bundle with total set, entry empty, and a self link — computed with a single COUNT(*) over the filtered queryset before pagination. Other _summary values are not specially handled (the full resources are returned).

Components

FileResponsibility
core/fhir/fhir_config.jsonDeclares mapped_resources (field mappings + meta.__interaction / __criteria) and aux_resources (resourceType + __interaction), plus each resource’s __search params and __sortDate.
core/fhir/config.pyLoads the JSON once at import; exposes get_resource_mapping, mapped_interactions / aux_interactions, mapped_criteria, mapped_model_name, mapped_search_params / aux_search_params, mapped_sort_date / aux_sort_date, and get_config_errors() (validation, see below).
core/fhir/search.pyThe US Core search-param, _sort and _summary layer: mapped ORM filters and the auxiliary Postgres JSONB query builder (jsonb_path_exists / #>> via RawSQL).
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 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()).

  6. Every __search spec is well-formed: each entry declares a type in token/identifier/code/string/reference/date/const, a const carries a value, and every other type carries a non-empty path.

Auxiliary resources, FhirSource, meta.source & 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 meta.source discriminator & the _source search param

Every resource JHE serves carries a meta.source recording where it came from, and that single field is the JHE-native-vs-imported discriminator that routes both reads and writes:

Reads choose their store with the standard _source search param (a resource-level uri param matching meta.source), so a search is always single-table:

_sourcestore
absentmapped (JHE-native default)
https://jupyterhealth.org/jhemapped
https://jupyterhealth.org/fhir/fhir-source/<id>aux — that one source
_source:below=https://jupyterhealth.org/fhir/fhir-source/aux — all imported
anything else (an external URI, a typo)empty (no stored meta.source matches)

For a pure-aux type (no mapped model, e.g. Condition) an absent _source means aux — there is no mapped store to default to. _source:below is a string-prefix match on the uri (FHIR’s :below modifier); only the .../fhir-source/ base is recognized, and it works precisely because every imported source URI nests under that one prefix. The X-JHE-FHIR-Source-ID header plays no part in reads (it is write-only).

Why this URI shape:

The constants and parser live in core/models/fhir_aux_resource.py (JHE_NATIVE_SOURCE, JHE_FHIR_SOURCE_BASE, fhir_source_uri, parse_fhir_source_id); the read routing is FHIRResourceView._resolve_source_target / _search_source in core/views/fhir.py. JHE_NATIVE_SOURCE must stay in sync with the meta.source literal in fhir_config.json.

The X-JHE-FHIR-Source-ID header (writes)

A write (create/update/delete) must name the FhirSource the new/edited row links to. There are two ways, in precedence order (resolve_fhir_source_context):

  1. the X-JHE-FHIR-Source-ID header (the source pk) — authoritative when present (it wins over the body);

  2. the resource body’s own meta.source (.../fhir-source/<id>) — the preferred, read/write-symmetric way. A bundle-level meta.source is never consulted: FHIR defines no inheritance from a Bundle to its entries, so each entry must carry its own.

Naming no source (neither the header nor a parseable body meta.source) is 400. The source is then resolved to (patient, fhir_source):

An unknown source is 400 and a source the user may not use is 403. Whichever way the source was named, the stored meta.source is (over)written to the canonical .../fhir-source/<id> by apply_jhe_extensions, so the persisted provenance is always normalized and trustworthy regardless of what the client sent.

The R4 import endpoint (/fhir-import/R4, fhir_import.py) still requires the header — it gates the whole request up front (a missing header is a request-level 400, not a per-entry outcome) — since it is a bulk R4→R5 conversion. The meta.source fallback applies to the native FHIR/R5 single-create and bundle-batch paths.

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). 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