Skip to content

About

Contract-driven conversion of delimited and fixed-width records into FHIR JSON

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

103 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fhir-utils

Reads delimited and fixed-width record files and maps them to FHIR JSON resources. Column mapping, code translation and resource shaping are declared in a JSON data contract, so onboarding a new source extract is a configuration change, not a patch.

Build

cargo build --release                                        # target/release/fhir-utils
cargo build --release --target x86_64-unknown-linux-musl     # fully static binary
cargo test                                                   # unit, integration, end-to-end

Commands

Command

Purpose

fhir-utils validate -f <contract.json>

validate a data contract

fhir-utils validate --print -f <contract.json>

print the resolved contract, definitions inlined

fhir-utils convert -d <base> -o <out>

convert every input under <base>/input

fhir-utils convert -f <file> -c <config-dir> -o <out>

convert one file

fhir-utils transform -d <base>

print the normalized internal records as JSON lines

Flag

Meaning

Required

-f, --file

contract file for validate, input file for convert

yes for validate

-d, --directory

base directory holding input/ and config/

one of -d or -f

-c, --config-dir

directory holding the contract; may repeat, later ones are fallback layers

with -f, unless MAPPING_CONFIG_DIRECTORY is set

--contract-digest

expected SHA-256 of the contract; a mismatch stops the run

no

-o, --output

output directory, created when missing

yes for convert

--strict

fail on a task error, an empty group key or a row that builds nothing

no

--bundle [<type>]

one bundle per group key: collection (default), batch, transaction

no

--verbose

per-file read, tasks, build and write timings on standard error

no

-d and -f are mutually exclusive; -f without -c reads the contract from MAPPING_CONFIG_DIRECTORY, and -d requires both <base>/input and <base>/config. Exit code 0 on success, 1 on an invalid contract, a missing path or a row conversion failure. Inputs, code maps and join sources ending in .gz are decompressed transparently. transform takes the convert flags without -o and prints one record per row on standard output, which shows what the tasks produced before resource conversion.

Quickstart

Source extract input/patients.csv with headers that do not match the internal model:

MRN,LastName,FirstName,Sex,DOB,SSN
1234567,Smith,Ann,F,1980-01-02,123-45-6789
7654321,Jones,Bob,M,1975-11-30,

Code map config/sex.csv, two columns, header names fixed:

source_value,target_value
F,female
M,male

Contract config/data-contract.json:

{
  "general": {
    "timeZone": "America/New_York",
    "tenantId": "hospital-a",
    "streamType": "live"
  },
  "fileDefinitions": {
    "patients": {
      "fileType": "csv",
      "resourceType": "Patient",
      "groupByKey": "MRN",
      "tasks": [
        {
          "task": "rename_columns",
          "column_map": {
            "MRN": "mrn",
            "LastName": "nameLast",
            "FirstName": "nameFirst",
            "Sex": "gender",
            "DOB": "birthDate",
            "SSN": "ssn"
          }
        },
        { "task": "map_codes", "code_map": { "gender": "sex.csv" } }
      ]
    }
  }
}

Run:

fhir-utils validate -f config/data-contract.json
fhir-utils convert -f input/patients.csv -c config -o out
fhir-utils convert --strict -f input/patients.csv -c config -o out

Result:

out/1234567/1234567-Patient-patients-00001.json
out/7654321/7654321-Patient-patients-00001.json

The key patients matches the file name patients.csv, groupByKey names the source column because grouping happens before tasks, and sex.csv resolves against the configuration directory. Target column names come from the record fields below; a name outside that list travels through the pipeline but never reaches the resource.

Contract

Key

Description

Required

general.timeZone

IANA zone used to localize naive datetimes

yes

general.tenantId

tenant identifier injected into every row

yes

general.streamType

live or historical

yes

general.assigningAuthority

identifier system for PI, MR, AN, VN, RI, RXN

no

general.emptyFieldValues

values read as null

no

general.regexFilenames

match file definition keys as regular expressions

no

general.codeSystems

code system names to canonical URLs: object or JSON file name

no

fileDefinitions.<key>.fileType

csv or fixed-width

no

fileDefinitions.<key>.resourceType

one of the resource keys

yes

fileDefinitions.<key>.groupByKey

source column that groups the output

yes

fileDefinitions.<key>.idKey

column or list of columns deriving a stable resource id

no

fileDefinitions.<key>.skiprows

line count to drop from the top, or zero-based line numbers

no

fileDefinitions.<key>.headers

column names, names with comments, or names with widths

no

fileDefinitions.<key>.tasks

ordered tasks appended after the default chain

no

  • Every general key is injected into each row as a constant, including keys beyond the ones listed above, so a contract can carry its own values into tasks.

  • A key matches when it is a substring of the input file name without its extension; longest key first, then alphabetically.

  • Fixed-width files need a width form of headers; otherwise the first remaining line supplies the column names.

  • valueDelimiter may be several characters; quoted fields need a one-character delimiter.

  • Input files matching no definition are skipped and named in the summary.

  • Repeated column names keep the last column of the row.

Resource keys

Patient, AllergyIntolerance, Condition, Encounter, Immunization, Observation, Location, Organization, Practitioner, Procedure, MedicationUse, MedicationAdministration, MedicationRequest, MedicationStatement, DocumentReference, DiagnosticReport, Unstructured, Basic. Keys match regardless of case; patient reads as Patient.

Record fields

Task output must use these names; anything else is ignored by the converter. Every row also carries filePath and rowNum. ssn is normalized to nine digits without dashes and repeated-digit values are rejected. a → b renames a to b.

Model

Fields

Patient

patientInternalId, accountNumber, ssn, ssnSystem, patientSourceRecordId, driversLicense, driversLicenseSystem, mrn, assigningAuthority, nameFirst, nameFirstMiddle, nameMiddle, nameLast, nameFirstMiddleLast, prefix, suffix, birthDate, deceasedDateTime, deceasedBoolean, multipleBirthBoolean, multipleBirthInteger, address1, address2, city, state, postalCode, country, addressText, telecomPhone, race, raceSystem, raceText, ethnicity, ethnicitySystem, ethnicityText, gender, ageInWeeksForAgeUnder2Years, ageInMonthsForAgeUnder8Years

AllergyIntolerance

patientInternalId, accountNumber, ssn, ssnSystem, mrn, encounterInternalId, encounterNumber, resourceInternalId, assigningAuthority, allergySourceRecordId, allergyCategory, allergyType, allergyRecordedDateTime, allergyCode, allergyCodeSystem, allergyCodeText, allergyCriticality, allergyManifestationCode, allergyManifestationSystem, allergyManifestationText, allergyManifestationCodeList, allergyClinicalStatusCode, allergyVerificationStatusCode, allergyOnsetStartDateTime, allergyOnsetEndDateTime

Condition

patientInternalId, accountNumber, ssn, ssnSystem, mrn, encounterInternalId, encounterNumber, resourceInternalId, assigningAuthority, conditionSourceRecordId, encounterClaimType, conditionCategory, conditionRecordedDateTime, conditionOnsetDateTime, conditionAbatementDateTime, conditionClinicalStatus, conditionVerificationStatus, conditionDiagnosisRank, conditionDiagnosisUse, conditionCode, conditionCodeSystem, conditionCodeText, conditionSeverityCode, conditionSeveritySystem, conditionSeverityText, conditionChronicity

Encounter

patientInternalId, accountNumber, ssn, ssnSystem, mrn, encounterNumber, resourceInternalId, assigningAuthority, encounterSourceRecordId, encounterStatus, encounterClassCode/Text/System, encounterPriorityCode/Text/CodeSystem, encounterStartDateTime, encounterEndDateTime, encounterLengthValue, encounterLengthUnits, encounterReasonCode/System/Text, hospitalizationAdmitSourceCode/Text/System, hospitalizationReAdmissionCode/Text/System, hospitalizationDischargeDispositionCode/Text/System, encounterParticipantSequenceId, encounterParticipantTypeCode/Text/CodeSystem, practitionerInternalId, practitionerNPI, practitionerNameLast, practitionerNameFirst, practitionerGender, practitionerRoleText, practitionerRoleCodes, practitionerRoleCodesSystem, practitionerSpecialtyCodes, practitionerSpecialtyCodesSystem, practitionerSpecialtyText, encounterLocationSequenceId, encounterLocationPeriodStart, encounterLocationPeriodEnd, locationResourceInternalId, locationName, locationTypeCode, locationTypeText, locationTypeCodeSystem, encounterStatusHistory, encounterInsuredEntryId, encounterInsuredRank, encounterInsuredCategoryCode/System/Text, encounterClaimType, encounterDrgCode

Immunization

patientInternalId, accountNumber, ssn, ssnSystem, mrn, resourceInternalId, assigningAuthority, immunizationSourceRecordId, immunizationDoseQuantity, immunizationDoseUnit, immunizationDoseText, encounterNumber, encounterInternalId, organizationResourceInternalId, organizationName, immunizationRouteCode/System/Text, immunizationSiteCode/System/Text, immunizationStatus, immunizationStatusReasonCode/System/Text, immunizationVaccineCode/System/Display/Text, immunizationVaccineCodeList, immunizationDate, immunizationExpirationDate

Location

assigningAuthority, locationResourceInternalId → resourceInternalId, locationName, locationTypeCode, locationTypeText, locationTypeCodeSystem

MedicationUse

resourceType, patientInternalId, accountNumber, ssn, ssnSystem, mrn, medicationSourceRecordId, encounterInternalId, encounterNumber, encounterClaimType, encounterClassCode, resourceInternalId, medicationRxNumber, assigningAuthority, medicationUseStatus, medicationUseCategoryCode/System/Text, medicationUseOccuranceDateTime, medicationCode/Display/System/Text, medicationCodeList, medicationUseRouteCode/System/Text, medicationUseRouteList, medicationUseDosageText/Value/Unit, medicationValidityStart, medicationValidityEnd, medicationRefills, medicationQuantity, medicationAuthoredOn, medicationRequestIntent

Observation

patientInternalId, accountNumber, ssn, ssnSystem, mrn, encounterNumber, encounterInternalId, resourceInternalId, assigningAuthority, observationSourceRecordId, observationStatus, observationCategory, observationDateTime, practitionerNPI, practitionerInternalId, observationCode, observationCodeSystem, observationCodeText, observationCodeList, observationValue, observationValueUnits, observationValueDataType, observationRefRange, observationRefRangeLow, observationRefRangeHigh, observationRefRangeText, observationInterpretationCode/System/Display/Text

Organization

assigningAuthority, organizationResourceInternalId → resourceInternalId, organizationName

Practitioner

assigningAuthority, practitionerInternalId → resourceInternalId, practitionerNPI → identifier_practitionerNPI, practitionerNameLast, practitionerNameFirst, practitionerNameText, practitionerGender, practitionerRoleText, practitionerRoleCode, practitionerRoleCodeList, practitionerRoleCodeSystem, practitionerSpecialtyCode, practitionerSpecialtyCodeList, practitionerSpecialtyCodeSystem, practitionerSpecialtyText

Procedure

patientInternalId, accountNumber, ssn, ssnSystem, mrn, procedureSourceRecordId, encounterInternalId, encounterNumber, encounterClaimType, resourceInternalId, assigningAuthority, procedureStatus, procedurePerformedDateTime, procedureCategory, procedureCategorySystem, procedureCategoryText, procedureCode/System/Display/Text, procedureCodeList, procedureModifierList, procedureModifierSystem, procedureEncounterSequenceId, practitionerInternalId, practitionerNPI, practitionerNameLast, practitionerNameFirst, practitionerGender, practitionerRoleText, practitionerRoleCode, practitionerRoleCodeSystem, practitionerSpecialtyCode, practitionerSpecialtyCodeSystem, practitionerSpecialtyText

Basic

baseSystem, tokenList, patientInternalIdentifier, created_date, otherIdentifierList

Unstructured

resourceType, patientInternalId, accountNumber, ssn, ssnSystem, mrn, encounterNumber, resourceInternalId, encounterInternalId, assigningAuthority, resourceStatus, documentStatus, documentTypeCode, documentTypeCodeSystem, documentTypeCodeText, documentDateTime, documentAttachmentContentType, documentAttachmentContent, documentAttachmentTitle, practitionerInternalId, practitionerNPI, practitionerNameLast, practitionerNameFirst

Tasks

Default chain, in order: add_row_num, set_nan_to_none, remove_whitespace_from_columns, copy_columns into groupByKey, then add_constant per general key plus filePath and configResourceType. User tasks run after it.

Task

Parameters

add_constant

name, value as string, number, boolean or list

add_row_num

starting_index, default 1

append_list

source_columns, target_column, discard_if_duplicate

build_object_array

entry_class, target_column, entries

change_case

columns, casing as UPPER or LOWER

coerce

types mapping columns to STRING, INTEGER, DECIMAL, BOOLEAN, BASE64, CLEAN_SSN or NAMED_UUID; rejected values become null

compare_to_date

column, target_column; optional compare_date, comparison, true_string, false_string

conditional_column

source_column, target_column, condition_map

conditional_column_update

source_column, target_column, condition_map

condition_column_with_prerequisite

+ prerequisite_column, prerequisite_match

convert_to_list

column, separator default ,

copy_columns

columns, target_column, value_separator

derive_resource_id

column; fills empty resourceInternalId with a name-based UUID of it

extract_regex

column, pattern, target_columns; capture groups land in the targets, no match leaves them null

filter_to_columns

source_column, target_columns, filters

find_not_null_value

columns, target_column

format_date

columns, date_format

join_data

secondary_data_source, join_type, join_on; optional source_type, reader_params

map_codes

code_map, whose per-column map takes a default for unlisted values

merge_diff

diff: value sets a constant, "" or null deletes, name[x] patches the first prefixed column

remove_whitespace_from_columns

none

rename_columns

column_map

replace_text

column_name, match, replacement, options

set_nan_to_none

none

split_column

column_name, new_column_names, delimiter or indices

split_row

columns, split_column_name, split_value_column_name

validate_value

column_name, regex, no_match_replacement

  • Tasks run in order over one chunk at a time; a failing task is logged and the chain continues.

  • A comment key on a task is accepted and ignored.

  • List valued cells are strings joined with |.

  • condition_map and code_map take an inline object or a CSV file with source_value and target_value columns; a null or empty target clears the cell.

  • A .json map file holds one flat object or many named tables addressed as file.json#table; a many-table file used without #table fails naming the tables. An object target maps a code to a {code, system, display, text} concept whose parts land in <column>System, <column>Display and <column>Text. default works like default.

  • A value the code map does not list passes through unchanged, which is reported once per file and column; add a default entry to control it.

  • Unknown tasks and unknown or missing parameters are rejected when the contract loads, as is an unknown key inside a file definition or a missing fileDefinitions block.

  • A task target that neither a later task nor the resource reads is reported by validate and warned about during convert; --strict makes it fail the run. It catches a misspelled field name, which otherwise drops the value silently.

Output

  • Path: <out>/<groupByKey>/<groupByKey>-<resourceType>-<safeFileId>-<nnnnn>.json.

  • --bundle writes <out>/<groupByKey>/<groupByKey>-Bundle.json instead: one bundle per group key with fullUrl per entry and meta.lastUpdated; batch and transaction entries carry a PUT request, which pairs with idKey for idempotent reloads.

  • The counter runs per group key, resource type and source file.

  • JSON is pretty printed with sorted keys.

  • Empty strings are pruned from every resource, and objects or arrays left empty by that collapse with them; numbers, booleans and explicit null values survive.

  • safeFileId keeps [A-Za-z0-9-] and maps every other character to _.

  • Group key directories keep letters, digits, spaces and dots; path separators, control characters and \ : * ? " < > | become , and an all-dot key becomes .

  • Re-running overwrites matching file names and leaves earlier files in place.

  • The summary counts files, rows read, resources written and rows that produced none.

  • A resource repeating an earlier one’s type and id in the same run is merged into it and counted in the summary: missing fields fill in, arrays union, and a conflicting scalar keeps the first value with a warning. Generated ids never repeat, so this only affects mapped or idKey-derived ids. A remote output keeps the first occurrence unmerged, as it cannot be reread.

  • A row whose record carries no usable code produces no resource; those rows are counted in the summary, listed by row number on standard error, and fatal with --strict.

  • Resource ids are generated per run unless the contract maps resourceInternalId, so a reload creates new ids; the urn:id:extID identifier and the urn:id:source-file-id extension, which carries <file>:<rowNum>, stay stable and are the keys to match on.

  • idKey names one column or a list of columns whose values, prefixed with the resource type, seed a name-based UUID that fills an empty resourceInternalId after the tasks run, so a reload keeps the same ids. A list composes a hierarchical seed such as patient plus code; a row missing any part keeps a generated id.

  • The summary goes to standard output; warnings and errors go to standard error.

  • Warnings print by default; RUST_LOG=info adds a line per file naming the definition that matched it and, once converted, its row and resource counts.

Dates

Date parsing is shared by format_date, compare_to_date and the resource builders. Ambiguous numeric forms read month first.

Form

Examples

ISO

1980-01-02, 1980-01-02T10:00, 1980-01-02 10:00:00, fractional seconds

Compact

19800102, 198001021000, 19800102100000

Month first

01/02/1980, 01-02-1980, with optional 10:00 or 10:00:00

Named month

02-Jan-1980, 02 Jan 1980, Jan 2, 1980, January 2, 1980

Separators

1980/01/02, 1980.01.02

  • format_date reformats parsed values and passes the rest through; unparsed values are reported once per file and column.

  • A bare year between 1900 and 2200, or a year-month such as 1985-06, is valid for date fields and keeps its precision.

  • Naive datetimes are localized with general.timeZone; an offset embedded in the value wins over the contract zone.

Code systems

  • Short system names render as canonical URLs: ICD9, ICD10, ICD10PCS, LOINC, SNOMED, CPT, CVX, NDC, RXNORM, MESH, NCI, UMLS, HCPCS, ATC, CDT, UCUM; matching ignores case and common alternate spellings.

  • general.codeSystems adds or overrides entries: an inline object of name to URL, or the name of a JSON file resolved through the pluggable opener.

  • A name outside the table renders as urn:id:<value>; full URIs pass through.

Required field defaults

FHIR requires some fields the source extract may not carry. When a column is absent the converter fills these so the output loads into a server:

Resource

Field

Default

Observation, Encounter, Procedure, DiagnosticReport

status

unknown

MedicationAdministration, MedicationRequest, MedicationStatement

status

unknown

MedicationRequest

intent

order

Immunization

status

completed

DocumentReference

status

current

AllergyIntolerance

clinicalStatus

active unless the verification status is entered-in-error

Encounter

class

data absent temp-unknown

Immunization

occurrence

occurrenceString of unknown when no date is given

Map the real column whenever the source has one; the defaults keep output valid, they do not carry clinical meaning.

Error codes

Fatal errors print error E<code>: <message>. Codes are stable and grouped by phase; message text may change between releases.

Code

Meaning

E1101

invalid time zone

E1102

invalid stream type

E1103

unknown resource type

E1104

fixed width without name/width headers

E1105

unknown task

E1106

unknown task parameter

E1107

missing task parameter

E1108

contract or configuration error

E1109

no file definition matched

E1201

input parsing failed

E1202

file system error

E1301

row or output conversion failed

E1401

JSON serialization failed

E1501

writing output failed

Errors and strict mode

Class

Behaviour

Contract or validation error

run stops naming the key or the accepted values

Missing file definition

file skipped

Task failure

warning, pipeline continues; fatal with --strict

Task target no field reads

warning at load; fatal with --strict

Rows without a group key

warning once per file; fatal with --strict

Rows producing no resource

counted in the summary and listed; fatal with --strict

Reference to a resource the run did not write

warning listing up to five; fatal with --strict

Resource carrying only its type, id and meta

warning listing up to five; fatal with --strict

Unlisted code map value

warning once per file and column, value passes through

Input that is not UTF-8

run stops naming the file and the re-encoding command

Row conversion error

run stops with the group key and file

Configuration

Variable

Meaning

Default

CSV_BUFFER_SIZE

rows per chunk; 0 reads the whole file into memory

1000

MAPPING_CONFIG_DIRECTORY

contract directory used when -c is absent

/var/app/config

MAPPING_CONFIG_FILE_NAME

contract file name

data-contract.json

Settings are read once per process. Contracts, external file definitions, mapping files and join sources open through one pluggable opener: local paths and file:// URIs work out of the box, other schemes need a backend registered with opener::set_opener. The opener is scoped to the calling thread, so each worker thread installs its own. Output resources are written through the same opener when the output carries a URI scheme; a plain path writes to the local filesystem as before. The optimized-streaming build feature adds http:// and https:// sources, including a remote contract directory; other schemes still need a registered backend. Repeating -c layers configuration directories: the contract and every map or definition resolve from the first layer that has them, so a user layer ships only its overrides on top of a shared base. --contract-digest sha256hex verifies the contract bytes before the run, making a distributed contract reproducible.

About

Contract-driven conversion of delimited and fixed-width records into FHIR JSON

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages