Skip to main content

README.md

# fhir_ex

An Elixir library for working with [FHIR R5](https://www.hl7.org/fhir/R5/) resources. Provides typed structs, JSON serialization, and field-level validation for the resources and data types used in **lab exam ordering**, **patient admissions**, and **clinical messaging** workflows.

## Features

- Typed Elixir structs for all FHIR R5 data types and resources
- JSON encoding (`struct → JSON`) and decoding (`JSON → struct`) via [Jason](https://github.com/michalmuskala/jason)
- Nil fields omitted from JSON output; `resourceType` injected automatically
- Polymorphic `[x]` fields (e.g. `value[x]`, `event[x]`, `endpoint[x]`) represented as tagged tuples
- Bundle creation helpers for `transaction`, `batch`, `searchset`, `collection`, `document`, and `message` bundles
- Validation returning `{:ok, struct}` or `{:validation_error, [errors]}` with JSON-path error locations
- Recursive nested validation — errors from deep inside a struct carry their full path

## Installation

Add `fhir_ex` to your dependencies:

```elixir
def deps do
  [
    {:fhir_ex, "~> 0.1.0"}
  ]
end
```

## Quick start

### Lab order (ServiceRequest)

```elixir
alias FhirEx.Resources.ServiceRequest
alias FhirEx.Types.{CodeableConcept, Coding, Identifier, Reference, Annotation}

order = %ServiceRequest{
  id: "order-001",
  identifier: [
    %Identifier{system: "http://hospital.org/orders", value: "ORD-2024-0042"}
  ],
  status: "active",
  intent: "order",
  priority: "routine",
  code: %CodeableConcept{
    coding: [%Coding{system: "http://loinc.org", code: "58410-2", display: "CBC panel"}],
    text: "Complete Blood Count"
  },
  subject: %Reference{reference: "Patient/patient-001"},
  encounter: %Reference{reference: "Encounter/enc-001"},
  requester: %Reference{reference: "Practitioner/pract-001"},
  authored_on: "2024-01-15T08:00:00Z",
  occurrence: {:date_time, "2024-01-15T10:00:00Z"},
  note: [%Annotation{text: "Patient must fast for 8 hours before blood draw."}]
}

json = FhirEx.JSON.encode!(order)
```

<details>
<summary>JSON output</summary>

```json
{
  "resourceType": "ServiceRequest",
  "id": "order-001",
  "status": "active",
  "intent": "order",
  "priority": "routine",
  "authoredOn": "2024-01-15T08:00:00Z",
  "occurrenceDateTime": "2024-01-15T10:00:00Z",
  "code": {
    "coding": [{"system": "http://loinc.org", "code": "58410-2", "display": "CBC panel"}],
    "text": "Complete Blood Count"
  },
  "subject": {"reference": "Patient/patient-001"},
  "encounter": {"reference": "Encounter/enc-001"},
  "requester": {"reference": "Practitioner/pract-001"},
  "identifier": [{"system": "http://hospital.org/orders", "value": "ORD-2024-0042"}],
  "note": [{"text": "Patient must fast for 8 hours before blood draw."}]
}
```

</details>

### Lab result (Observation)

```elixir
alias FhirEx.Resources.Observation
alias FhirEx.Types.{CodeableConcept, Coding, Reference, Quantity}

observation = %Observation{
  id: "obs-hemoglobin",
  status: "final",
  category: [
    %CodeableConcept{
      coding: [%Coding{
        system: "http://terminology.hl7.org/CodeSystem/observation-category",
        code: "laboratory"
      }]
    }
  ],
  code: %CodeableConcept{
    coding: [%Coding{system: "http://loinc.org", code: "718-7", display: "Hemoglobin"}],
    text: "Hemoglobin"
  },
  subject: %Reference{reference: "Patient/patient-001"},
  encounter: %Reference{reference: "Encounter/enc-001"},
  effective: {:date_time, "2024-01-15T09:30:00Z"},
  issued: "2024-01-15T10:00:00Z",
  value: {:quantity, %Quantity{value: 14.5, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"}},
  reference_range: [
    %{
      low:  %Quantity{value: 12.0, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"},
      high: %Quantity{value: 17.5, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"},
      text: "12.0–17.5 g/dL"
    }
  ]
}
```

### Patient admission (Encounter)

```elixir
alias FhirEx.Resources.Encounter
alias FhirEx.Types.{CodeableConcept, Coding, Reference, Period}

encounter = %Encounter{
  id: "enc-001",
  status: "in-progress",
  class: [
    %CodeableConcept{
      coding: [%Coding{
        system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
        code: "IMP",
        display: "inpatient encounter"
      }]
    }
  ],
  subject: %Reference{reference: "Patient/patient-001"},
  service_provider: %Reference{reference: "Organization/org-001"},
  actual_period: %Period{start: "2024-01-15T08:00:00Z"},
  admission: %{
    admit_source: %CodeableConcept{
      coding: [%Coding{
        system: "http://terminology.hl7.org/CodeSystem/admit-source",
        code: "emd",
        display: "From accident/emergency department"
      }]
    }
  }
}
```

### Workflow task (Task)

```elixir
alias FhirEx.Resources.Task
alias FhirEx.Types.{CodeableConcept, Coding, Reference}

task = %Task{
  id: "task-001",
  status: "requested",
  intent: "order",
  priority: "routine",
  code: %CodeableConcept{
    coding: [%Coding{system: "http://snomed.info/sct", code: "257196009"}],
    text: "Collect specimen"
  },
  for_subject: %Reference{reference: "Patient/patient-001"},
  encounter: %Reference{reference: "Encounter/enc-001"},
  authored_on: "2024-01-15T09:00:00Z"
}
```

> **Note on `for_subject`:** The FHIR field `for` is a reserved Elixir keyword. This library exposes it as `for_subject` on the struct and serialises it as `"for"` in JSON.

### OperationOutcome

```elixir
alias FhirEx.Resources.OperationOutcome

# Builder helpers
outcome = OperationOutcome.error("not-found", "Patient/999 was not found")
outcome = OperationOutcome.warning("processing", "Slow response time")
outcome = OperationOutcome.ok("Patient created successfully")

# Full struct
outcome = %OperationOutcome{
  issue: [
    %{
      severity: "error",
      code: "required",
      details: %FhirEx.Types.CodeableConcept{text: "Patient.name is required"},
      expression: ["Patient.name"]
    }
  ]
}
```

### Bundles

`FhirEx.Resources.Bundle` provides builder helpers for the most common bundle types. `Bundle.entry/2` wraps any resource struct and accepts `request:`, `search:`, `response:`, and `full_url:` options.

#### Transaction bundle

```elixir
alias FhirEx.Resources.Bundle

bundle = Bundle.transaction([
  Bundle.entry(patient, request: %{method: "POST", url: "Patient"}),
  Bundle.entry(obs,     request: %{method: "PUT",  url: "Observation/obs-001"})
])

json = FhirEx.JSON.encode!(bundle)
```

#### Batch bundle

```elixir
bundle = Bundle.batch([
  Bundle.entry(nil, request: %{method: "GET", url: "Patient/patient-001"}),
  Bundle.entry(nil, request: %{method: "GET", url: "Observation?subject=Patient/patient-001"})
])
```

#### Searchset bundle

```elixir
bundle = Bundle.searchset(
  [
    Bundle.entry(patient, search: %{mode: "match", score: 1.0}),
    Bundle.entry(related, search: %{mode: "include"})
  ],
  total: 2
)
```

#### Message bundle

A `message` bundle must have a `MessageHeader` as its first entry.

```elixir
alias FhirEx.Resources.{Bundle, MessageHeader}
alias FhirEx.Types.{Coding, Reference}

header = %MessageHeader{
  event: {:coding, %Coding{
    system: "http://example.org/fhir/message-events",
    code: "patient-link"
  }},
  source: %{
    name: "LabSystem",
    software: "LabApp",
    version: "3.1.45",
    endpoint: {:url, "https://lab.example.org/fhir/messaging"}
  },
  destination: [
    %{
      name: "HIS",
      endpoint: {:url, "https://his.example.org/fhir"},
      receiver: %Reference{reference: "Organization/org-001"}
    }
  ],
  focus: [%Reference{reference: "Patient/patient-001"}]
}

bundle = Bundle.new(
  type: "message",
  entry: [
    Bundle.entry(header),
    Bundle.entry(patient)
  ]
)
```

#### Decoding bundles

When a Bundle is decoded from JSON, each entry's `:resource` is a raw `map()` (the resource type is only known at runtime). Re-decode it with the appropriate module:

```elixir
%Bundle{} = bundle = FhirEx.JSON.decode!(json, Bundle)

patient =
  bundle.entry
  |> hd()
  |> Map.get(:resource)
  |> FhirEx.Resources.Patient.from_map()
```

## JSON

### Encoding

```elixir
json = FhirEx.JSON.encode!(resource)
```

- `resourceType` is injected automatically for resources
- `nil` fields are stripped from the output
- Struct field names are converted to FHIR camelCase keys (`birth_date` → `"birthDate"`)

### Decoding

```elixir
resource = FhirEx.JSON.decode!(json_string, FhirEx.Resources.Patient)
```

Pass the target module as the second argument. Nested structs are reconstructed automatically.

### Round-trip

```elixir
patient
|> FhirEx.JSON.encode!()
|> FhirEx.JSON.decode!(Patient)
```

## Polymorphic fields

FHIR uses `[x]` to indicate a field that can hold one of several types (e.g. `value[x]`, `occurrence[x]`). In this library they are represented as **tagged tuples** in Elixir and serialised to the correct FHIR JSON key.

| Elixir (internal) | FHIR JSON key | Resource |
|---|---|---|
| `{:date_time, "2024-01-15T10:00:00Z"}` | `"occurrenceDateTime"` | ServiceRequest |
| `{:period, %Period{...}}` | `"occurrencePeriod"` | ServiceRequest |
| `{:quantity, %Quantity{...}}` | `"valueQuantity"` | Observation |
| `{:codeable_concept, %CodeableConcept{...}}` | `"valueCodeableConcept"` | Observation |
| `{:string, "Positive"}` | `"valueString"` | Observation |
| `{:boolean, true}` | `"valueBoolean"` | Observation |
| `{:range, %Range{...}}` | `"valueRange"` | Observation |
| `{:coding, %Coding{...}}` | `"eventCoding"` | MessageHeader |
| `{:canonical, "http://..."}` | `"eventCanonical"` | MessageHeader |
| `{:url, "https://..."}` | `"endpointUrl"` | MessageHeader source/destination |
| `{:reference, %Reference{...}}` | `"endpointReference"` | MessageHeader source/destination |

## Validation

```elixir
alias FhirEx.Validation
alias FhirEx.Validation.Error

case Validation.validate(resource) do
  {:ok, resource} ->
    # proceed

  {:validation_error, errors} ->
    IO.puts(Error.format_all(errors))
end
```

`validate!/1` raises `ArgumentError` instead of returning the tuple:

```elixir
Validation.validate!(resource)
```

### Error structure

Each error carries a JSON-pointer style `path` list and a human-readable `message`:

```elixir
%Error{path: ["name", "1", "use"], message: "must be one of: usual | official | ..."}
```

Format helpers:

```elixir
Error.format(error)       #=> "name.1.use: must be one of: usual | official | ..."
Error.format_all(errors)  #=> newline-separated string of all errors
```

Nested struct errors are validated recursively and their paths are prefixed with the parent field and list index, so you always know exactly where the problem is.

### What is validated

| Type / Resource | Rules |
|---|---|
| `Coding` | `code` has no whitespace; `system` is a non-empty URI |
| `CodeableConcept` | at least one of `coding` or `text` present; nested codings valid |
| `Identifier` | `use` is a valid code; `system` is a non-empty URI |
| `HumanName` | `use` is a valid code; at least one name part present |
| `Address` | `use` and `type` are valid codes |
| `ContactPoint` | `system`/`use` are valid codes; `value` required when `system` is set |
| `Period` | `start`/`end` are valid FHIR dateTimes; `start` ≤ `end` |
| `Quantity` | `comparator` is a valid code; `system` required when `code` is set |
| `Range` | both quantities valid; `low.system` matches `high.system` |
| `Reference` | at least one of `reference`/`identifier`/`display`; reference string format |
| `Annotation` | `text` required; `authorReference` and `authorString` are mutually exclusive |
| `Extension` | `url` required; at most one `value[x]`; `value[x]` and nested extensions mutually exclusive |
| `Meta` | `versionId` matches FHIR id format; `lastUpdated` is a valid instant |
| `Narrative` | `status`/`div` required; `div` must be an XHTML `<div>` element |
| `Patient` | `gender`, `birthDate` format; `deceased*` and `multipleBirth*` mutual exclusivity |
| `Encounter` | `status` required and valid |
| `ServiceRequest` | `status`, `intent`, `subject` required; `priority` valid if set |
| `Observation` | `status`, `code` required; `value[x]` type validated; component `code` required |
| `Specimen` | `status` valid if set; `receivedTime` is a valid instant |
| `DiagnosticReport` | `status`, `code` required |
| `Task` | `status`, `intent` required and valid; `priority` valid if set; `authoredOn`/`lastModified` are valid dateTimes |
| `OperationOutcome` | `issue` required and non-empty; each issue must have valid `severity` and `code` |
| `Bundle` | `type` required and valid; `total` only on `searchset`/`history`; entry `request` method and url validated; link `relation` and `url` required |
| `MessageHeader` | `event` required; `source` required with its `endpoint`; destination `endpoint` required; `response.code` valid if response present |
| `Subscription` | `status`, `topic`, `channelType` required; `content` valid if set; `end` is a valid instant; each `filterBy` requires `filterParameter` + `value`, `comparator` validated; each `parameter` requires `name` + `value` |

## Resources

| Module | FHIR resource | Primary use |
|---|---|---|
| `FhirEx.Resources.Patient` | [Patient](https://www.hl7.org/fhir/R5/patient.html) | Demographics and identifiers |
| `FhirEx.Resources.Practitioner` | [Practitioner](https://www.hl7.org/fhir/R5/practitioner.html) | Ordering clinician, result interpreter |
| `FhirEx.Resources.Organization` | [Organization](https://www.hl7.org/fhir/R5/organization.html) | Hospital, laboratory, clinic |
| `FhirEx.Resources.Encounter` | [Encounter](https://www.hl7.org/fhir/R5/encounter.html) | Admissions and visits |
| `FhirEx.Resources.ServiceRequest` | [ServiceRequest](https://www.hl7.org/fhir/R5/servicerequest.html) | Lab exam orders |
| `FhirEx.Resources.Observation` | [Observation](https://www.hl7.org/fhir/R5/observation.html) | Lab results and measurements |
| `FhirEx.Resources.Specimen` | [Specimen](https://www.hl7.org/fhir/R5/specimen.html) | Biological samples |
| `FhirEx.Resources.DiagnosticReport` | [DiagnosticReport](https://www.hl7.org/fhir/R5/diagnosticreport.html) | Report bundling observations |
| `FhirEx.Resources.Task` | [Task](https://www.hl7.org/fhir/R5/task.html) | Workflow tasks (collect specimen, review result, etc.) |
| `FhirEx.Resources.OperationOutcome` | [OperationOutcome](https://www.hl7.org/fhir/R5/operationoutcome.html) | Errors, warnings, and informational responses |
| `FhirEx.Resources.Bundle` | [Bundle](https://www.hl7.org/fhir/R5/bundle.html) | Container for multiple resources (transaction, searchset, message, …) |
| `FhirEx.Resources.MessageHeader` | [MessageHeader](https://www.hl7.org/fhir/R5/messageheader.html) | First entry of a `message` Bundle; identifies the event and routing |
| `FhirEx.Resources.Subscription` | [Subscription](https://www.hl7.org/fhir/R5/subscription.html) | Push-based event subscription (rest-hook, websocket, message) |

## Data types

| Module | FHIR type | Notes |
|---|---|---|
| `FhirEx.Types.Primitives` | — | `@type` aliases for all 18 FHIR R5 primitives |
| `FhirEx.Types.Coding` | [Coding](https://www.hl7.org/fhir/R5/datatypes.html#Coding) | system + code + display |
| `FhirEx.Types.CodeableConcept` | [CodeableConcept](https://www.hl7.org/fhir/R5/datatypes.html#CodeableConcept) | `[Coding]` + text |
| `FhirEx.Types.Identifier` | [Identifier](https://www.hl7.org/fhir/R5/datatypes.html#Identifier) | MRN, NPI, accession number |
| `FhirEx.Types.Reference` | [Reference](https://www.hl7.org/fhir/R5/references.html) | Relative, absolute, logical, or contained |
| `FhirEx.Types.HumanName` | [HumanName](https://www.hl7.org/fhir/R5/datatypes.html#HumanName) | |
| `FhirEx.Types.Address` | [Address](https://www.hl7.org/fhir/R5/datatypes.html#Address) | |
| `FhirEx.Types.ContactPoint` | [ContactPoint](https://www.hl7.org/fhir/R5/datatypes.html#ContactPoint) | Phone, email, etc. |
| `FhirEx.Types.Period` | [Period](https://www.hl7.org/fhir/R5/datatypes.html#Period) | Start/end datetime range |
| `FhirEx.Types.Quantity` | [Quantity](https://www.hl7.org/fhir/R5/datatypes.html#Quantity) | Measured amount with UCUM unit |
| `FhirEx.Types.Range` | [Range](https://www.hl7.org/fhir/R5/datatypes.html#Range) | Low/high Quantity pair |
| `FhirEx.Types.Ratio` | [Ratio](https://www.hl7.org/fhir/R5/datatypes.html#Ratio) | Numerator/denominator (INR, titers) |
| `FhirEx.Types.Annotation` | [Annotation](https://www.hl7.org/fhir/R5/datatypes.html#Annotation) | Text note with author + time |
| `FhirEx.Types.Extension` | [Extension](https://www.hl7.org/fhir/R5/extensibility.html) | FHIR extensibility mechanism |
| `FhirEx.Types.Meta` | [Meta](https://www.hl7.org/fhir/R5/resource.html#Meta) | Version, profile, tags |
| `FhirEx.Types.Narrative` | [Narrative](https://www.hl7.org/fhir/R5/narrative.html) | XHTML human-readable summary |

## FHIR R5 notes

This library targets **FHIR R5**. Key differences from R4 that are reflected in the structs:

- `Encounter.class` is now `[CodeableConcept]` (was a single `Coding` in R4)
- `Encounter.hospitalization` has been renamed to `Encounter.admission`
- `Encounter.location.form` replaces `location.physicalType`
- `ServiceRequest.patientInstruction` is now a structured array
- `Observation` component validation requires `code` on each component
- `Bundle` link `relation` is now typed as a `code` (not free text)

## Development

```sh
mix deps.get
mix test
mix docs   # generate HTML documentation
```

The test suite has 319 tests covering struct construction, JSON round-trips, nil field omission, all polymorphic field variants, validation rules, nested error path propagation, and Bundle integration scenarios.

## License

MIT