Extending & adapting
The adaptor is small and deliberately hand-authored, so extending it is straightforward. Everything lives in three files:
src/utils.js— FHIR datatype helpers, the idempotent-upsert plumbing (cond,urn,upsertEntry,dedupeEntries), and GCP auth (gcpAccessToken).src/builders.js— ICR resource builders + the ESPEN form transforms, and the ICR code-system / identifier / extension constants (SYS,CS,EXT).src/Adaptor.js— the operations (execute,upsertBundle) and re-exported FHIR ops.
The builder pattern
Every builder returns { ref, entry } and is registered for idempotent upsert via
upsertEntry(resourceType, system, value, resource):
system+valueare the business identifier — the adaptor embeds it in the resource and builds the conditional-PUT URL from it, so re-running upserts in place.refis a deterministicurn:uuidyou use to reference this resource from siblings in the same transaction (e.g. a Group'sgeography→ its village Location).
import { coding, concept, upsertEntry } from './utils.js';
export function myResource({ value, name, locationRef }) {
return upsertEntry('Observation', SYS.myThing, value, {
status: 'final',
code: concept(CS.someCode, 'my-code', 'My code'),
subject: { reference: locationRef }, // a urn:uuid ref from another builder
valueString: name,
});
}
- Put the identifier in the resource body, not just the conditional URL —
upsertEntrydoes this for you. A resource with noidentifierduplicates on every run. QuestionnaireResponse.identifieris0..1(a single object), while most resources are0..*(an array).upsertEntryspecial-cases this.
Add a new form transform
A form transform takes one ODK OData submission and returns an array of transaction entries.
Reuse adminLocations to build (and reference) the admin hierarchy consistently with the
other forms:
export function fromMyForm(sub) {
const date = (sub.__system?.submissionDate || '2026-01-01').slice(0, 10);
const { entries, hfRef, villageRef } = adminLocations({
state: sub.p_state, district: sub.p_district, hf: sub.p_health_facility,
village: sub.p_location, villageId: sub.p_location_id,
});
const out = [...entries];
out.push(myResource({ value: `${sub.p_location_id}:thing`, name: sub.some_field, locationRef: villageRef }).entry);
return out;
}
Then a job just does:
fn(state => { state.entries = state.data.value.flatMap(b.fromMyForm); return state; });
upsertBundle($.entries);
Because adminLocations uses the same deterministic identifiers everywhere, references line
up across forms — a SupplyDelivery from the receipt form points at the same Location the
registration form created.
Target a different FHIR store
Nothing about the builders is Google-specific. To point at HAPI or another R4 store, drop the
service_account from the credential and use baseUrl (+ access_token/basic auth if the
store needs it). The adaptor only mints a GCP token when a service_account is present.
Adapt for a different IG or country profile
The ICR conformance detail is centralized in the SYS, CS, and EXT constants at the top
of builders.js (canonical base, code systems, identifier systems, extension URLs) and in
the individual builders. To retarget another jurisdiction's profiles:
- Point the constants at the new canonical base / code systems.
- Adjust the builders' element structure to the new profiles.
- Keep
upsertEntry/adminLocations/ the GCP auth as-is.
For a large, stable IG it's worth generating builders from the StructureDefinitions (the
approach OpenFn's fhir-ndr-et adaptor takes with @openfn/generate-fhir) instead of
hand-authoring — a planned evolution for fhir-icr.
Contributing to the OpenFn catalog
To publish @openfn/language-fhir-icr to docs.openfn.org:
add the OpenFn build tooling (build.config.js + @openfn/buildtools), two logo assets
(assets/rectangle.png, assets/square.png), and a changeset, then open a PR into
OpenFn/adaptors as packages/fhir-icr. CI publishes
to npm and the catalog on merge.