Skip to content

Repository files navigation

LOOP BMIP FHIR to SPHN Mapping

FHIR to SPHN mappings for LOOP BMIP using FHIR Mapping Language (FML)

StructureDefinitions for the target LOOP schema is defined using FHIR Shorthand (FSH)

Install

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

Matchbox

  • Requires JDK 21 and maven
    • sudo apt install openjdk-21-jdk
    • sudo apt install maven
  • Clone matchbox
cd ${HOME}
git clone https://github.com/ahdis/matchbox.git

StructureDefinitions

Generate StructureDefinitions (unshorten FSH):

sushi build

Build Implementation Guide (IG)

./_updatePublisher.sh
./_genonce.sh

Running transformations using Matchbox

Run local matchbox server:

cd 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.yaml

Note: 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

Compile maps / execute transformation

  • POST maps and exectute transformation using REST against local matchbox server:

Postprocessing

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

(Containerized) Testing Framework

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.

Setup

Install Python dependencies (Python 3.10+):

pip install -r tests/requirements-test.txt

Docker is required to run the Matchbox container.

Development workflow

The recommended workflow for developing and testing maps:

  1. Build the Docker image:
docker compose -f docker/docker-compose.yml build
  1. Start the Matchbox container once:
docker compose -f docker/docker-compose.yml up -d
  1. Edit a .map file in maps/.

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

  2. Run the full suite before committing:

pytest tests/ -v

To stop the container when done:

docker compose -f docker/docker-compose.yml down

docker/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 up

Behind 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-*.pem

Skipping StructureDefinition rebuild

When only .map files have changed, use --skip-sds to skip the sushi build and SD upload for faster iteration:

pytest tests/ -v --skip-sds

Running all tests without starting the container first

Use the --start-container flag to let pytest manage the container lifecycle automatically (starts before tests, stops after):

pytest tests/ -v --start-container

Test fixtures

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

Test structure

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.

Coverage verification (mutation testing)

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-run

The report shows each rule as COVERED (a test detected the removal) or MISSING (no test failed when the rule was removed).

CI/CD

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

Docker image

Pull a specific version or latest:

docker pull ghcr.io/bal-dmu/fhir2sphn:0.1.2
docker pull ghcr.io/bal-dmu/fhir2sphn:latest

How to release

The 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 --tags

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

About

LOOP BMIP FHIR to SPHN

Resources

Stars

0 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages