FHIR to SPHN mappings for LOOP BMIP using FHIR Mapping Language (FML)
- Mappings: maps
StructureDefinitions for the target LOOP schema is defined using FHIR Shorthand (FSH)
- Install SUSHI from https://github.com/FHIR/sushi
- For building the IG, jekyll is required:
sudo apt install jekyll
- Optional VS Code extensions for development:
An FML engine is required like Matchbox (recommended) or HAPI-FHIR Validator Cli.
Recommended versions: Use at least 4.0.1 for Matchbox or later. These releases contain critical performance improvements required to transform large amount of data (see hapifhir/org.hl7.fhir.core#1704 / ahdis/matchbox#362) and corresponding issues #1703 / #1699
- Requires JDK 21 and maven
sudo apt install openjdk-21-jdksudo apt install maven
- Clone matchbox
cd ${HOME}
git clone https://github.com/ahdis/matchbox.gitGenerate StructureDefinitions (unshorten FSH):
sushi build./_updatePublisher.sh
./_genonce.shcd matchbox/matchbox-server
mvn clean install -DskipTests spring-boot:run -Dspring-boot.run.jvmArguments="-Xmx4g" \
-Dspring-boot.run.directories=../../LOOP_FHIR2SPHN/output \
-Dspring-boot.run.arguments=--spring.config.additional-location=file:../../LOOP_FHIR2SPHN/with-preload/application.yamlNote: After updating the StructureDefinitions, the database needs to be cleared to force re-loading of the IG package (output/package.tgz):
rm -rf matchbox/matchbox-server/database- POST maps and exectute transformation using REST against local matchbox server:
- See transform.http
cat temp/result.json | jq 'walk(if type == "object" then with_entries(.key = (if .key == "reference" then "id" else .key end)) else . end)' | jq 'walk(if type == "object" then with_entries(.key = (if .key != "id" and .key != "iri" and .key != "termid" and .key != "content" and .key != "target_concept" then "sphn:" else "" end ) + .key) else . end)'An automated test suite verifies that FML mapping rules produce the expected SPHN output. Tests run against a containerized Matchbox server: the framework uploads the .map files from the local maps/ directory, transforms FHIR Bundles, and asserts on the JSON output. This makes the edit-upload-test cycle fast when developing maps.
Install Python dependencies (Python 3.10+):
pip install -r tests/requirements-test.txtDocker is required to run the Matchbox container.
The recommended workflow for developing and testing maps:
- Build the Docker image:
docker compose -f docker/docker-compose.yml build- Start the Matchbox container once:
docker compose -f docker/docker-compose.yml up -d-
Edit a
.mapfile inmaps/. -
Run the relevant tests (maps are re-uploaded automatically at the start of each pytest session):
pytest tests/maps/test_allergy_intolerance_to_allergy.py -v-
Iterate: edit the map, re-run tests. To force re-upload of maps after changes, start a new pytest session (each session uploads all maps fresh).
-
Run the full suite before committing:
pytest tests/ -vTo stop the container when done:
docker compose -f docker/docker-compose.yml downdocker/docker-compose.yml pins no platform, so the image is built for the host
architecture: linux/amd64 on CI (which is what gets published) and linux/arm64
natively on Apple Silicon. Building natively is considerably faster and avoids
Rosetta emulation bugs in the IG Publisher step. To force linux/amd64 locally --
for example to run against the published image instead of building it -- set
DOCKER_DEFAULT_PLATFORM:
DOCKER_DEFAULT_PLATFORM=linux/amd64 docker compose -f docker/docker-compose.yml upBehind a corporate proxy that intercepts TLS, drop the proxy's root CA at
docker/ca.crt before building (git-ignored, optional) so the Java IG Publisher
trusts it. Capture the self-signed root (the last cert in the chain, not the leaf):
openssl s_client -connect tx.fhir.org:443 -showcerts </dev/null 2>/dev/null \
| awk '/-----BEGIN CERTIFICATE-----/{n++} n>0{print > ("/tmp/c-" n ".pem")}'
cp "$(ls /tmp/c-*.pem | sort -V | tail -1)" docker/ca.crt && rm /tmp/c-*.pemWhen only .map files have changed, use --skip-sds to skip the sushi build and SD upload for faster iteration:
pytest tests/ -v --skip-sdsUse the --start-container flag to let pytest manage the container lifecycle automatically (starts before tests, stops after):
pytest tests/ -v --start-containerThe test infrastructure is defined in tests/conftest.py and provides the following pytest fixtures:
| Fixture | Scope | Description |
|---|---|---|
matchbox_container |
session | Manages the Docker container lifecycle. With --start-container, starts/stops the container automatically. Otherwise expects it already running. |
matchbox_ready |
session | Waits for the Matchbox server to be healthy (polls /metadata endpoint). |
maps_uploaded |
session | Runs sushi build inside the container, uploads StructureDefinitions, then uploads all .map files from maps/ in dependency order (Utils first, BundleToLoopSphn last). Use --skip-sds to skip the sushi build and SD upload for faster iteration when only maps have changed. |
transform_bundle |
session | Returns a function transform_bundle(bundle_dict, source_map=None) that POSTs a FHIR Bundle to the Matchbox $transform endpoint and returns the result as a dict. Defaults to BundleToLoopSphn. |
make_bundle |
function | Factory that creates a FHIR Bundle wrapping one or more resources: make_bundle(patient, observation, ...). |
base_patient |
function | A minimal Patient resource with an identifier, for use in bundles that require a patient. |
Tests are organized by map file in tests/maps/: Each test constructs a minimal FHIR Bundle, transforms it via Matchbox, and asserts on the resulting SPHN output.
The script tests/verify_map_coverage.py checks whether each mapping rule is covered by at least one test. It works by commenting out one rule at a time, re-running the relevant tests, and checking that at least one test fails:
# Verify coverage for all maps (requires running Matchbox container)
python tests/verify_map_coverage.py
# Verify a specific map
python tests/verify_map_coverage.py --map AllergyIntoleranceToAllergy.map
# Dry run: list all extracted rules without running tests
python tests/verify_map_coverage.py --dry-runThe report shows each rule as COVERED (a test detected the removal) or MISSING (no test failed when the rule was removed).
The GitHub Actions workflow (.github/workflows/ci.yml) runs on every push. It builds the image with the updated SDs and maps, starts a container and runs the tests.
On a version tag push, a release job additionally:
- Creates a GitHub release with a changelog of commits since the last tag
- Pushes the Docker image to GitHub Container Registry (GHCR): ghcr.io/bal-dmu/fhir2sphn
Pull a specific version or latest:
docker pull ghcr.io/bal-dmu/fhir2sphn:0.1.2
docker pull ghcr.io/bal-dmu/fhir2sphn:latestThe release job triggers on any tag starting with a digit and containing a dot.
Tag the commit you want to release (any branch — it does not need to be on main)
and push the tag:
# Stable release
git tag 0.1.2
git push --tags
# Release candidate (pre-release)
git tag 0.1.2-rc.1
git push --tagsThe release job then creates the GitHub release with the commit changelog, pushes
the Docker image tagged with the version, and uploads a pinned docker-compose.yml.
Stable vs. release candidate — the behavior is driven by the tag name:
| Tag format | :latest image |
GitHub release | |
|---|---|---|---|
| Stable | X.Y.Z (e.g. 0.1.2) |
updated | normal release |
| Release candidate | anything else (e.g. 0.1.2-rc.1) |
not updated | marked pre-release |
So an RC is fully published to GHCR under its own tag, but it will not move
:latest and is flagged as a pre-release in the GitHub Releases UI.